├── .nvmrc ├── .npmrc ├── _dev └── cli.md ├── src ├── deps.ts ├── contract │ ├── __test_assets__ │ │ ├── src │ │ │ ├── generated │ │ │ │ └── .gitignore │ │ │ └── dao │ │ │ │ ├── user │ │ │ │ ├── upsertUser.ts │ │ │ │ └── findAllByName.ts │ │ │ │ └── home │ │ │ │ └── upsertHome.ts │ │ ├── codegen.sql.only-types.yml │ │ ├── codegen.sql.yml │ │ └── schema │ │ │ ├── tables │ │ │ ├── user.sql │ │ │ └── home.sql │ │ │ └── functions │ │ │ ├── upsert_home.sql │ │ │ └── upsert_user.sql │ └── commands │ │ ├── generate.integration.test.ts │ │ └── generate.ts ├── domain │ ├── index.ts │ ├── objects │ │ ├── QueryDeclaration.ts │ │ ├── SqlSubqueryReference.ts │ │ ├── ResourceDeclaration.ts │ │ ├── index.ts │ │ ├── TypeDefinitionOfQuerySelectExpression.ts │ │ ├── TypeDefinitionOfResourceInput.ts │ │ ├── TypeDefinitionOfResourceColumn.ts │ │ ├── TypeDefinitionOfResourceTable.ts │ │ ├── TypeDefinitionReference.ts │ │ ├── TypeDefinitionOfQueryTableReference.ts │ │ ├── TypeDefinitionOfResourceView.ts │ │ ├── TypeDefinitionOfQueryInputVariable.ts │ │ ├── TypeDefinitionOfResourceFunction.ts │ │ ├── GeneratorConfig.ts │ │ └── TypeDefinitionOfQuery.ts │ └── constants.ts ├── logic │ ├── config │ │ └── getConfig │ │ │ ├── index.ts │ │ │ ├── readConfig │ │ │ ├── index.ts │ │ │ ├── utils │ │ │ │ └── readYmlFile.ts │ │ │ └── readConfig.integration.test.ts │ │ │ ├── getAllPathsMatchingGlobs │ │ │ ├── getAllPathsMatchingGlobs.ts │ │ │ └── getAllPathsMatchingGlobs.test.ts │ │ │ ├── getConfig.ts │ │ │ └── getConfig.test.ts │ ├── __test_assets__ │ │ ├── directory.ts │ │ ├── exampleProject │ │ │ ├── mysql │ │ │ │ ├── src │ │ │ │ │ ├── generated │ │ │ │ │ │ └── .gitignore │ │ │ │ │ ├── others │ │ │ │ │ │ ├── queryWithoutName.ts │ │ │ │ │ │ └── queryUserNameView.ts │ │ │ │ │ └── dao │ │ │ │ │ │ ├── user │ │ │ │ │ │ ├── findAllByName.test.ts │ │ │ │ │ │ ├── findAllByName.integration.test.ts │ │ │ │ │ │ └── findAllByName.ts │ │ │ │ │ │ └── iceCream │ │ │ │ │ │ ├── findById.ts │ │ │ │ │ │ └── findAllNew.ts │ │ │ │ ├── schema │ │ │ │ │ ├── views │ │ │ │ │ │ └── view_user_name.sql │ │ │ │ │ ├── tables │ │ │ │ │ │ ├── ingredient.sql │ │ │ │ │ │ ├── ice_cream.sql │ │ │ │ │ │ ├── user.sql │ │ │ │ │ │ ├── image.sql │ │ │ │ │ │ └── communication_channel.sql │ │ │ │ │ └── functions │ │ │ │ │ │ ├── hash_string.sql │ │ │ │ │ │ └── upsert_image.sql │ │ │ │ └── codegen.sql.yml │ │ │ ├── mysql-dbdump │ │ │ │ ├── src │ │ │ │ │ └── generated │ │ │ │ │ │ └── .gitignore │ │ │ │ └── codegen.sql.yml │ │ │ ├── postgres │ │ │ │ ├── src │ │ │ │ │ ├── generated │ │ │ │ │ │ └── .gitignore │ │ │ │ │ ├── others │ │ │ │ │ │ ├── queryWithoutName.ts │ │ │ │ │ │ ├── queryUserNameView.ts │ │ │ │ │ │ └── queryUserWithMatchingIcecream.ts │ │ │ │ │ └── dao │ │ │ │ │ │ ├── user │ │ │ │ │ │ ├── findAllByName.test.ts │ │ │ │ │ │ ├── findAllByName.integration.test.ts │ │ │ │ │ │ └── findAllByName.ts │ │ │ │ │ │ └── iceCream │ │ │ │ │ │ ├── findById.ts │ │ │ │ │ │ └── findAllNew.ts │ │ │ │ ├── schema │ │ │ │ │ ├── views │ │ │ │ │ │ └── view_user_name.sql │ │ │ │ │ ├── functions │ │ │ │ │ │ ├── get_answer_to_life.sql │ │ │ │ │ │ └── upsert_image.sql │ │ │ │ │ └── tables │ │ │ │ │ │ ├── photo.sql │ │ │ │ │ │ ├── ingredient.sql │ │ │ │ │ │ ├── ice_cream.sql │ │ │ │ │ │ └── user.sql │ │ │ │ └── codegen.sql.yml │ │ │ └── postgres-noqueries │ │ │ │ ├── src │ │ │ │ └── generated │ │ │ │ │ └── .gitignore │ │ │ │ ├── codegen.sql.yml │ │ │ │ └── schema │ │ │ │ ├── functions │ │ │ │ └── get_answer_to_life.sql │ │ │ │ └── tables │ │ │ │ ├── ingredient.sql │ │ │ │ └── ice_cream.sql │ │ ├── queries │ │ │ ├── upsert_jerb.sql │ │ │ ├── find_image_by_id.sql │ │ │ ├── upsert_suggestion.sql │ │ │ ├── find_providers_with_work_count.sql │ │ │ ├── upsert_profile_with_subquery_function.sql │ │ │ ├── upsert_email.sql │ │ │ ├── upsert_profile_with_subselect.sql │ │ │ ├── find_users_by_last_name.sql │ │ │ ├── find_users_by_last_name_in.sql │ │ │ ├── find_users_by_last_name_no_aliases.sql │ │ │ ├── select_suggestion.sql │ │ │ ├── find_all_suggestions_by_normalized_source.sql │ │ │ ├── find_job_by_id.sql │ │ │ ├── find_all_chat_messages_by_thread.sql │ │ │ ├── find_all_chat_messages_by_thread.lessthan_or_equalsto_or_null.sql │ │ │ ├── upsert_train_with_unnesting_uuids.sql │ │ │ ├── find_train_by_id.sql │ │ │ └── find_train_by_uuid.sql │ │ ├── functions │ │ │ ├── get_answer_to_life.postgres.sql │ │ │ ├── hash_string.mysql.sql │ │ │ ├── upsert_photo.postgres.sql │ │ │ └── upsert_image.mysql.sql │ │ ├── tables │ │ │ ├── photo.postgres.sql │ │ │ ├── job.postgres.sql │ │ │ ├── image.mysql.sql │ │ │ ├── suggestion_version.mysql.sql │ │ │ └── job_version.postgres.sql │ │ └── views │ │ │ ├── view_suggestion_current.sql │ │ │ ├── view_email_current.sql │ │ │ └── view_job_current.sql │ ├── common │ │ ├── extractSqlFromFile │ │ │ ├── __test_assets__ │ │ │ │ ├── dontExportSql.ts │ │ │ │ ├── onlyExportSql.ts │ │ │ │ └── importAndExportThingsIncludingSql.ts │ │ │ ├── index.ts │ │ │ ├── extractSqlFromSqlFile.ts │ │ │ ├── __snapshots__ │ │ │ │ └── extractSqlFromTsFile.test.ts.snap │ │ │ ├── extractSqlFromTsFile.ts │ │ │ ├── extractSqlFromFile.ts │ │ │ └── extractSqlFromTsFile.test.ts │ │ └── readFileAsync.ts │ ├── sqlToTypeDefinitions │ │ ├── query │ │ │ ├── __test_assets__ │ │ │ │ ├── query_without_name.sql │ │ │ │ ├── find_all_by_name_excluding_one_field.sql │ │ │ │ └── find_with_subselect_in_select_expressions.sql │ │ │ ├── common │ │ │ │ ├── flattenSqlByReferencingAndTokenizingSubqueries │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── getTokenForSubqueryReference.ts │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ ├── breakSqlIntoNestedSqlArraysAtParentheses.test.ts.snap │ │ │ │ │ │ └── flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive.test.ts.snap │ │ │ │ │ ├── flattenSqlByReferencingAndTokenizingSubqueries.ts │ │ │ │ │ ├── breakSqlIntoNestedSqlArraysAtParentheses.ts │ │ │ │ │ ├── breakSqlIntoNestedSqlArraysAtParentheses.test.ts │ │ │ │ │ ├── flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive.test.ts │ │ │ │ │ ├── extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings.ts │ │ │ │ │ ├── extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings.test.ts │ │ │ │ │ └── flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive.ts │ │ │ │ └── throwErrorIfTableReferencePathImpliesTable.ts │ │ │ ├── extractTableReferencesFromQuerySql │ │ │ │ ├── constants.ts │ │ │ │ ├── tryToExtractTableReferenceSqlSetFromQuerySql.ts │ │ │ │ ├── extractTableReferenceSqlSetFromQuerySql.test.ts │ │ │ │ └── extractTableReferencesFromQuerySql.ts │ │ │ ├── extractNameFromQuerySql.ts │ │ │ ├── extractInputVariablesFromQuerySql │ │ │ │ ├── extractInputVariableTokensFromQuerySql.ts │ │ │ │ └── extractInputVariableTokensFromQuerySql.test.ts │ │ │ ├── getTypeDefinitionFromQueryDeclaration.ts │ │ │ ├── extractNameFromQuerySql.test.ts │ │ │ ├── extractSelectExpressionsFromQuerySql │ │ │ │ ├── extractTypeDefinitionReferenceFromSubqueryReferenceToken.test.ts │ │ │ │ ├── extractTypeDefinitionReferenceFromSubqueryReferenceToken.ts │ │ │ │ ├── extractTypeDefinitionReferenceFromSelectExpressionSql.ts │ │ │ │ └── extractTypeDefinitionFromSelectExpressionSql.ts │ │ │ └── extractTypeDefinitionFromQuerySql.ts │ │ ├── resource │ │ │ ├── common │ │ │ │ ├── __test_assets__ │ │ │ │ │ ├── job.insides.sql │ │ │ │ │ └── suggestion_version.insides.sql │ │ │ │ ├── castCommasInParensToPipesForTokenSafety.test.ts │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── castCommasInParensToPipesForTokenSafety.test.ts.snap │ │ │ │ ├── castCommasInParensToPipesForTokenSafety.ts │ │ │ │ └── extractDataTypeFromColumnOrArgumentDefinitionSql.test.ts │ │ │ ├── function │ │ │ │ ├── extractTypeDefinitionFromFunctionSql.ts │ │ │ │ ├── extractInputsFromFunctionSql │ │ │ │ │ ├── extractTypeDefinitionFromFunctionInputSql.ts │ │ │ │ │ ├── extractInputsFromFunctionSql.ts │ │ │ │ │ ├── extractTypeDefinitionFromFunctionInputSql.test.ts │ │ │ │ │ └── extractInputsFromFunctionSql.test.ts │ │ │ │ └── extractOutputFromFunctionSql │ │ │ │ │ └── extractOutputFromFunctionSql.test.ts │ │ │ ├── table │ │ │ │ ├── extractTypeDefinitionFromColumnSql.ts │ │ │ │ ├── extractTypeDefinitionFromColumnSql.test.ts │ │ │ │ ├── extractTypeDefinitionFromTableSql.test.ts │ │ │ │ └── extractTypeDefinitionFromTableSql.ts │ │ │ ├── getTypeDefinitionFromResourceDeclaration.ts │ │ │ ├── view │ │ │ │ ├── extractTypeDefinitionFromViewSql.test.ts │ │ │ │ └── extractTypeDefinitionFromViewSql.ts │ │ │ └── extractResourceTypeAndNameFromDDL.ts │ │ └── getTypeDefinitionFromDeclaration.ts │ ├── typeDefinitionsToCode │ │ ├── common │ │ │ ├── defineTypescriptTypeFromDataTypeArray.ts │ │ │ ├── castQueryNameToTypescriptTypeName.ts │ │ │ ├── castResourceNameToTypescriptTypeName.test.ts │ │ │ ├── castResourceNameToTypescriptTypeName.ts │ │ │ ├── defineTypescriptTypeFromReference │ │ │ │ ├── defineTypescriptTypeFromFunctionReference.test.ts │ │ │ │ ├── defineTypescriptTypeFromFunctionReference.ts │ │ │ │ └── defineTypescriptTypeFromReference.ts │ │ │ └── defineTypescriptTypeFromDataTypeArrayOrReference.ts │ │ ├── query │ │ │ ├── __snapshots__ │ │ │ │ └── defineTypescriptQueryFunctionForQuery.test.ts.snap │ │ │ ├── defineTypescriptQueryFunctionForQuery.test.ts │ │ │ └── defineTypescriptQueryFunctionForQuery.ts │ │ ├── resource │ │ │ ├── table │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── defineTypescriptTypesForTable.test.ts.snap │ │ │ │ ├── defineTypescriptTypesForTable.ts │ │ │ │ └── defineTypescriptTypesForTable.test.ts │ │ │ └── view │ │ │ │ ├── __snapshots__ │ │ │ │ └── defineTypescriptTypesForView.test.ts.snap │ │ │ │ ├── defineTypescriptTypesForView.ts │ │ │ │ └── defineTypescriptTypesForView.test.ts │ │ └── getTypescriptTypesFromTypeDefinition.ts │ └── commands │ │ ├── utils │ │ └── fileIO.ts │ │ └── generate │ │ ├── defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions │ │ ├── QueryFunctionsOutputPathNotDefinedError.ts │ │ ├── utils │ │ │ ├── getRelativePathFromFileToFile.ts │ │ │ └── getRelativePathFromFileToFile.test.ts │ │ ├── defineTypescriptFunctionCodeForQueryFunctions.ts │ │ ├── defineTypescriptCommonExportsForQueryFunctions.ts │ │ ├── defineTypescriptExecuteQueryWithBestPracticesFunction.ts │ │ ├── defineTypescriptImportGeneratedTypesCodeForQueryFunctions.ts │ │ └── defineTypescriptImportQuerySqlCodeForQueryFunctions.ts │ │ ├── saveCode.ts │ │ ├── extractTypeDefinitionsFromDeclarations │ │ ├── extractTypeDefinitionsFromDeclarations.ts │ │ └── getTypeDefinitionFromDeclarationWithHelpfulError.ts │ │ ├── generate.ts │ │ └── defineTypescriptTypesFileCodeFromTypeDefinition │ │ └── defineTypescriptTypesFileCodeFromTypeDefinition.ts └── utils │ └── environment.ts ├── bin ├── run.cmd └── run ├── .husky ├── check.commit.sh ├── commit-msg ├── pre-commit ├── post-checkout ├── post-merge ├── check.nvm.sh ├── post-rewrite ├── check.yalc.sh └── check.lockfile.sh ├── nontyped_modules └── sql-strip-comments.d.ts ├── .prettierignore ├── jest.acceptance.env.ts ├── .gitattributes ├── jest.config.ts ├── provision └── docker │ └── integration_test_db │ ├── build-image.dockerfile │ ├── docker-compose.yml │ └── wait-for-mysql.sh ├── .vscode └── settings.json ├── commitlint.config.js ├── declapract.use.yml ├── .editorconfig ├── prettier.config.js ├── .gitignore ├── .depcheckrc.yml ├── acceptance └── environment.ts ├── jest.unit.env.ts ├── tsconfig.build.json ├── .github └── workflows │ ├── release.yml │ ├── publish.yml │ ├── review.yml │ ├── test.yml │ ├── .publish-npm.yml │ └── .install.yml ├── jest.integration.env.ts ├── tsconfig.json ├── jest.acceptance.config.ts ├── jest.integration.config.ts ├── jest.unit.config.ts └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.12.2 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /_dev/cli.md: -------------------------------------------------------------------------------- 1 | https://github.com/oclif/oclif 2 | -------------------------------------------------------------------------------- /src/deps.ts: -------------------------------------------------------------------------------- 1 | export { v4 as uuid } from 'uuid'; 2 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /.husky/check.commit.sh: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit "$1" 2 | -------------------------------------------------------------------------------- /src/contract/__test_assets__/src/generated/.gitignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /nontyped_modules/sql-strip-comments.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'sql-strip-comments'; 2 | -------------------------------------------------------------------------------- /src/domain/index.ts: -------------------------------------------------------------------------------- 1 | export * from './constants'; 2 | export * from './objects'; 3 | -------------------------------------------------------------------------------- /src/logic/config/getConfig/index.ts: -------------------------------------------------------------------------------- 1 | export { getConfig } from './getConfig'; 2 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/directory.ts: -------------------------------------------------------------------------------- 1 | export const TEST_ASSETS_ROOT_DIR = __dirname; 2 | -------------------------------------------------------------------------------- /src/logic/config/getConfig/readConfig/index.ts: -------------------------------------------------------------------------------- 1 | export { readConfig } from './readConfig'; 2 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/src/generated/.gitignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql-dbdump/src/generated/.gitignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/src/generated/.gitignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /src/logic/common/extractSqlFromFile/__test_assets__/dontExportSql.ts: -------------------------------------------------------------------------------- 1 | export const notSql = 821; 2 | -------------------------------------------------------------------------------- /src/logic/common/extractSqlFromFile/index.ts: -------------------------------------------------------------------------------- 1 | export { extractSqlFromFile } from './extractSqlFromFile'; 2 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres-noqueries/src/generated/.gitignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | . "$(dirname -- "$0")/check.commit.sh" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | . "$(dirname -- "$0")/check.yalc.sh" 5 | -------------------------------------------------------------------------------- /.husky/post-checkout: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | . "$(dirname -- "$0")/check.lockfile.sh" 5 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | . "$(dirname -- "$0")/check.lockfile.sh" 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | src/logic/__test_assets__/exampleProject/**/* 3 | src/contract/__test_assets__/src/generated/**/* 4 | -------------------------------------------------------------------------------- /jest.acceptance.env.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | jest.setTimeout(90000); // we're calling downstream apis 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # exclude package-lock from git diff; https://stackoverflow.com/a/72834452/3068233 2 | package-lock.json -diff 3 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/__test_assets__/query_without_name.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | u.id 3 | FROM 4 | user u 5 | WHERE u.id = :id 6 | -------------------------------------------------------------------------------- /.husky/check.nvm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # if exists a .nvmrc, then `nvm use`, to use the specified version 4 | [[ -f ".nvmrc" ]] && nvm use 5 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import config from './jest.unit.config'; 2 | 3 | // eslint-disable-next-line import/no-default-export 4 | export default config; 5 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/upsert_jerb.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | dgv.id, dgv.uuid 3 | FROM upsert_jerb(:ownerUuid, :title, :details, :dueDate) as dgv; 4 | -------------------------------------------------------------------------------- /provision/docker/integration_test_db/build-image.dockerfile: -------------------------------------------------------------------------------- 1 | FROM mysql:5.7 2 | 3 | ADD wait-for-mysql.sh /root/ 4 | RUN chmod +x /root/wait-for-mysql.sh 5 | -------------------------------------------------------------------------------- /.husky/post-rewrite: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | . "$(dirname -- "$0")/check.nvm.sh" 5 | . "$(dirname -- "$0")/check.lockfile.sh" 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "cSpell.words": [ 4 | "subqueries", 5 | "Subquery" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 'header-max-length': [1, 'always', 140] }, 4 | }; 5 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/flattenSqlByReferencingAndTokenizingSubqueries/index.ts: -------------------------------------------------------------------------------- 1 | export * from './flattenSqlByReferencingAndTokenizingSubqueries'; 2 | -------------------------------------------------------------------------------- /declapract.use.yml: -------------------------------------------------------------------------------- 1 | declarations: npm:declapract-typescript-ehmpathy 2 | useCase: npm-package 3 | variables: 4 | organizationName: ehmpathy 5 | projectName: sql-code-generator 6 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/src/others/queryWithoutName.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | SELECT 3 | u.id 4 | FROM 5 | user u 6 | WHERE u.id = :id 7 | `.trim(); 8 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/src/others/queryWithoutName.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | SELECT 3 | u.id 4 | FROM 5 | user u 6 | WHERE u.id = :id 7 | `.trim(); 8 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/find_image_by_id.sql: -------------------------------------------------------------------------------- 1 | select 2 | i.id, 3 | i.url, 4 | i.caption, 5 | i.alt_text, 6 | i.credit 7 | from image i 8 | where i.id = :id 9 | 10 | -------------------------------------------------------------------------------- /src/contract/__test_assets__/src/dao/user/upsertUser.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = upsert_user 3 | SELECT upsert_user( 4 | :firstName, 5 | :lastName 6 | ) as id; 7 | `.trim(); 8 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/schema/views/view_user_name.sql: -------------------------------------------------------------------------------- 1 | CREATE VIEW `view_user_name` AS 2 | SELECT 3 | u.id, 4 | u.first_name, 5 | u.last_name 6 | FROM user u; 7 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/src/dao/user/findAllByName.test.ts: -------------------------------------------------------------------------------- 1 | describe('should not get picked up', () => { 2 | it.skip('should not get picked up by glob', () => {}); 3 | }); 4 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/src/dao/user/findAllByName.test.ts: -------------------------------------------------------------------------------- 1 | describe('should not get picked up', () => { 2 | it.skip('should not get picked up by glob', () => {}); 3 | }); 4 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql-dbdump/codegen.sql.yml: -------------------------------------------------------------------------------- 1 | language: mysql 2 | dialect: 5.7 3 | resources: 4 | - "schema/**/*.sql" 5 | generates: 6 | types: src/generated/fromSql/types.ts 7 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/src/dao/user/findAllByName.integration.test.ts: -------------------------------------------------------------------------------- 1 | describe('should not get picked up', () => { 2 | it.skip('should not get picked up by glob', () => {}); 3 | }); 4 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres-noqueries/codegen.sql.yml: -------------------------------------------------------------------------------- 1 | language: postgres 2 | dialect: 10.7 3 | resources: 4 | - 'schema/**/*.sql' 5 | generates: 6 | types: src/generated/fromSql/types.ts 7 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/schema/views/view_user_name.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE VIEW view_user_name AS 2 | SELECT 3 | u.id, 4 | u.first_name, 5 | u.last_name 6 | FROM user u; 7 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/src/dao/user/findAllByName.integration.test.ts: -------------------------------------------------------------------------------- 1 | describe('should not get picked up', () => { 2 | it.skip('should not get picked up by glob', () => {}); 3 | }); 4 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/functions/get_answer_to_life.postgres.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION get_answer_to_life() 2 | RETURNS int 3 | LANGUAGE plpgsql 4 | AS $$ 5 | BEGIN 6 | RETURN 42; 7 | END; 8 | $$ 9 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/upsert_suggestion.sql: -------------------------------------------------------------------------------- 1 | 2 | SELECT upsert_suggestion( 3 | :suggestionSource, 4 | :externalId, 5 | :suggestedIdeaId, 6 | :status, 7 | :result 8 | ) as id; 9 | 10 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/find_providers_with_work_count.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | p.id, 3 | p.uuid, 4 | ( 5 | SELECT count(1) from work w where w.providerUuid = p.uuid 6 | ) as work_count 7 | FROM provider p 8 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractTableReferencesFromQuerySql/constants.ts: -------------------------------------------------------------------------------- 1 | export const JOIN_TYPES = ['JOIN', 'INNER JOIN', 'LEFT JOIN', 'OUTER JOIN']; 2 | export const TABLE_REFERENCE_TYPE = ['FROM', ...JOIN_TYPES]; 3 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/schema/functions/get_answer_to_life.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION get_answer_to_life() 2 | RETURNS int 3 | LANGUAGE plpgsql 4 | AS $$ 5 | BEGIN 6 | RETURN 42; 7 | END; 8 | $$ 9 | -------------------------------------------------------------------------------- /src/logic/common/extractSqlFromFile/__test_assets__/onlyExportSql.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | select 3 | u.id, 4 | concat(first_name, last_name) as full_name 5 | from user u 6 | where 1=1 7 | and name = :name; 8 | `; 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.husky/check.yalc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # https://github.com/wclr/yalc 4 | if [ "$(npx yalc check)" ]; then 5 | echo "✋ package.json has yalc references. Run 'npx yalc remove --all' to remove these local testing references." 6 | fi 7 | 8 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres-noqueries/schema/functions/get_answer_to_life.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION get_answer_to_life() 2 | RETURNS int 3 | LANGUAGE plpgsql 4 | AS $$ 5 | BEGIN 6 | RETURN 42; 7 | END; 8 | $$ 9 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // ref: http://json.schemastore.org/prettierrc 2 | 3 | module.exports = { 4 | trailingComma: 'all', 5 | tabWidth: 2, 6 | singleQuote: true, 7 | importOrder: ['^[./]'], 8 | importOrderSeparation: true, 9 | }; 10 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/src/others/queryUserNameView.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = select_user_name_from_view 3 | SELECT 4 | u.first_name, 5 | u.last_name 6 | FROM view_user_name u 7 | WHERE u.id = :userId 8 | `; 9 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/src/others/queryUserNameView.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = select_user_name_from_view 3 | SELECT 4 | u.first_name, 5 | u.last_name 6 | FROM view_user_name u 7 | WHERE u.id = :userId 8 | `; 9 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/common/defineTypescriptTypeFromDataTypeArray.ts: -------------------------------------------------------------------------------- 1 | import { DataType } from '../../../domain'; 2 | 3 | export const defineTypescriptTypeFromDataTypeArray = ({ 4 | type, 5 | }: { 6 | type: DataType[]; 7 | }) => type.join(' | '); 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *-debug.log 2 | *-error.log 3 | *.log 4 | *.tsbuildinfo 5 | .artifact 6 | .env 7 | .serverless 8 | .terraform 9 | .terraform.lock 10 | .yalc 11 | /.nyc_output 12 | /dist 13 | /lib 14 | /tmp 15 | /yarn.lock 16 | coverage 17 | dist 18 | node_modules 19 | -------------------------------------------------------------------------------- /src/contract/__test_assets__/src/dao/home/upsertHome.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = upsert_home 3 | SELECT upsert_home( 4 | (select u.id from user u where u.uuid = :userUuid), -- lookup user id for fk by uuid 5 | :address 6 | ) as id; 7 | `.trim(); 8 | -------------------------------------------------------------------------------- /src/logic/common/extractSqlFromFile/extractSqlFromSqlFile.ts: -------------------------------------------------------------------------------- 1 | import { readFileAsync } from '../readFileAsync'; 2 | 3 | export const extractSqlFromSqlFile = ({ filePath }: { filePath: string }) => 4 | readFileAsync({ filePath }); // the sql is everything in the file in this case 5 | -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('ts-node').register(); // register typescript REPL so that require() method can import typescript files as well as js 4 | 5 | require('@oclif/core') 6 | .run() 7 | .then(require('@oclif/core/flush')) 8 | .catch(require('@oclif/core/handle')); 9 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/upsert_profile_with_subquery_function.sql: -------------------------------------------------------------------------------- 1 | -- query_name = upsert_provider_profile 2 | SELECT upsert_provider_profile( 3 | (SELECT get_provider_id_by_uuid(:providerUuid)), 4 | :bannerImageUuid, 5 | :pictureImageUuid, 6 | :introduction 7 | ) as id; 8 | -------------------------------------------------------------------------------- /src/contract/__test_assets__/codegen.sql.only-types.yml: -------------------------------------------------------------------------------- 1 | language: mysql 2 | dialect: 5.7 3 | resources: 4 | - 'schema/**/*.sql' 5 | queries: 6 | - 'src/dao/**/*.ts' 7 | - '!src/**/*.test.ts' 8 | - '!src/**/*.test.integration.ts' 9 | generates: 10 | types: src/generated/fromSql/types.ts 11 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/src/dao/user/findAllByName.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = find_all_by_name 3 | SELECT 4 | u.id, 5 | u.first_name 6 | concat(u.first_name, u.last_name) as full_name 7 | FROM user u 8 | WHERE u.first_name = :firstName 9 | `.trim(); 10 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/upsert_email.sql: -------------------------------------------------------------------------------- 1 | -- query_name = upsert_email 2 | SELECT 3 | dgv.id, dgv.uuid 4 | FROM upsert_email( 5 | :externalId, 6 | :sesMessageId, 7 | :toReceiverId, 8 | :fromSenderId, 9 | :subject, 10 | :body, 11 | :status 12 | ) as dgv; 13 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/upsert_profile_with_subselect.sql: -------------------------------------------------------------------------------- 1 | -- query_name = upsert_provider_profile 2 | SELECT upsert_provider_profile( 3 | (SELECT p.id FROM view_provider_current p WHERE p.uuid = :serviceProviderUuid), 4 | :bannerImageUuid, 5 | :pictureImageUuid, 6 | :introduction 7 | ) as id; 8 | -------------------------------------------------------------------------------- /src/logic/config/getConfig/getAllPathsMatchingGlobs/getAllPathsMatchingGlobs.ts: -------------------------------------------------------------------------------- 1 | import fg from 'fast-glob'; 2 | 3 | export const getAllPathsMatchingGlobs = ({ 4 | globs, 5 | root, 6 | absolute, 7 | }: { 8 | globs: string[]; 9 | root: string; 10 | absolute?: boolean; 11 | }) => fg(globs, { cwd: root, absolute }); 12 | -------------------------------------------------------------------------------- /src/logic/common/readFileAsync.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | export const readFileAsync = ({ 4 | filePath, 5 | }: { 6 | filePath: string; 7 | }): Promise => 8 | new Promise((resolve) => { 9 | fs.readFile(filePath, 'utf8', (err, data) => { 10 | if (err) throw err; 11 | resolve(data); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/common/castQueryNameToTypescriptTypeName.ts: -------------------------------------------------------------------------------- 1 | import { pascalCase } from 'pascal-case'; 2 | 3 | export const castQueryNameToTypescriptTypeName = ({ 4 | name, 5 | }: { 6 | name: string; 7 | }) => { 8 | const queryNameInPascalCase = pascalCase(name); 9 | return `SqlQuery${queryNameInPascalCase}`; 10 | }; 11 | -------------------------------------------------------------------------------- /src/contract/__test_assets__/codegen.sql.yml: -------------------------------------------------------------------------------- 1 | language: mysql 2 | dialect: 5.7 3 | resources: 4 | - 'schema/**/*.sql' 5 | queries: 6 | - 'src/dao/**/*.ts' 7 | - '!src/**/*.test.ts' 8 | - '!src/**/*.test.integration.ts' 9 | generates: 10 | types: src/generated/fromSql/types.ts 11 | queryFunctions: src/generated/fromSql/queryFunctions.ts 12 | -------------------------------------------------------------------------------- /src/logic/commands/utils/fileIO.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import util from 'util'; 3 | 4 | // export these from a seperate file to make testing easier (i.e., easier to define the mocks) 5 | export const mkdir = util.promisify(fs.mkdir); 6 | export const writeFile = util.promisify(fs.writeFile); 7 | export const readFile = util.promisify(fs.readFile); 8 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/tables/photo.postgres.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE photo ( 2 | id bigserial NOT NULL, 3 | uuid uuid NOT NULL, 4 | created_at timestamp with time zone NOT NULL DEFAULT now(), 5 | url varchar NOT NULL, 6 | description varchar NULL, 7 | CONSTRAINT photo_pk PRIMARY KEY (id), 8 | CONSTRAINT photo_ux1 UNIQUE (url, description) 9 | ); 10 | -------------------------------------------------------------------------------- /.depcheckrc.yml: -------------------------------------------------------------------------------- 1 | ignores: 2 | - depcheck 3 | - declapract 4 | - declapract-typescript-ehmpathy 5 | - '@commitlint/config-conventional' 6 | - '@trivago/prettier-plugin-sort-imports' 7 | - '@tsconfig/node-lts-strictest' 8 | - core-js 9 | - ts-jest 10 | - husky 11 | - '@oclif/plugin-help' 12 | - oclif 13 | - '@types/yesql' 14 | - ts-node 15 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/__test_assets__/find_all_by_name_excluding_one_field.sql: -------------------------------------------------------------------------------- 1 | -- query_name = find_all_by_name 2 | SELECT 3 | u.id, 4 | u.first_name 5 | -- concat(u.first_name, u.last_name) as full_name; TODO: support mysql native functions https://github.com/uladkasach/sql-code-generator/issues/3 6 | FROM user u 7 | WHERE u.first_name = :firstName 8 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/flattenSqlByReferencingAndTokenizingSubqueries/getTokenForSubqueryReference.ts: -------------------------------------------------------------------------------- 1 | import { SqlSubqueryReference } from '../../../../../domain/objects/SqlSubqueryReference'; 2 | 3 | export const getTokenForSqlSubqueryReference = ({ 4 | reference, 5 | }: { 6 | reference: SqlSubqueryReference; 7 | }) => `__SSQ:${reference.id}__`; 8 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/find_users_by_last_name.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | u.id, 3 | CONCAT(u.first_name, " ", u.last_name) as full_name, 4 | u.age, 5 | p.number as phone_number, 6 | up.description 7 | FROM user u 8 | JOIN phone as p ON p.user_id = u.id 9 | JOIN view_user_profile_current up ON up.user_id = u.id 10 | WHERE 1=1 11 | and u.last_name = :lastName; 12 | -------------------------------------------------------------------------------- /.husky/check.lockfile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # posix style fn definition; https://stackoverflow.com/a/12469057/3068233 4 | changed () { 5 | git diff --name-only HEAD@{1} HEAD | grep "^$1" > /dev/null 2>&1 6 | } 7 | 8 | if changed 'package-lock.json'; then 9 | echo "📦 package-lock.json changed. Run 'npm install' to update your locally installed dependencies." 10 | fi 11 | -------------------------------------------------------------------------------- /acceptance/environment.ts: -------------------------------------------------------------------------------- 1 | import { Stage, stage } from '../src/utils/environment'; 2 | 3 | export const locally = process.env.LOCALLY === 'true'; // whether we want to acceptance test locally or deployed lambda 4 | 5 | export const testInProdOnly = stage === Stage.PRODUCTION ? test : test.skip; // allow testing in prod env only (sometimes we don't deploy certain things in dev / test) 6 | -------------------------------------------------------------------------------- /src/contract/__test_assets__/src/dao/user/findAllByName.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = find_all_by_name 3 | SELECT 4 | u.id, 5 | u.first_name 6 | -- concat(u.first_name, u.last_name) as full_name; TODO: support mysql native functions https://github.com/uladkasach/sql-code-generator/issues/3 7 | FROM user u 8 | WHERE u.first_name = :firstName 9 | `.trim(); 10 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/schema/tables/photo.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE photo ( 2 | id bigserial NOT NULL, 3 | uuid uuid NOT NULL, 4 | created_at timestamp with time zone NOT NULL DEFAULT now(), 5 | url varchar NOT NULL, 6 | description varchar NULL, 7 | CONSTRAINT photo_pk PRIMARY KEY (id), 8 | CONSTRAINT photo_ux1 UNIQUE (url, description) 9 | ); 10 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/find_users_by_last_name_in.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | u.id, 3 | CONCAT(u.first_name, " ", u.last_name) as full_name, 4 | u.age, 5 | p.number as phone_number, 6 | up.description 7 | FROM user u 8 | JOIN phone as p ON p.user_id = u.id 9 | JOIN view_user_profile_current up ON up.user_id = u.id 10 | WHERE 1=1 11 | and u.last_name IN (:candidateLastNames); 12 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/src/dao/user/findAllByName.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = find_all_by_name 3 | SELECT 4 | u.id, 5 | u.first_name 6 | -- concat(u.first_name, u.last_name) as full_name; TODO: support mysql native functions https://github.com/uladkasach/sql-code-generator/issues/3 7 | FROM user u 8 | WHERE u.first_name = :firstName 9 | `.trim(); 10 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/schema/tables/ingredient.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE ingredient ( 2 | id bigserial PRIMARY KEY, 3 | uuid uuid NOT NULL, 4 | created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 5 | name varchar NOT NULL, 6 | cost numeric(5, 2) NOT NULL, 7 | CONSTRAINT ingredient_pk PRIMARY KEY (id), 8 | CONSTRAINT ingredient_ux1 UNIQUE (name) 9 | ) 10 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/codegen.sql.yml: -------------------------------------------------------------------------------- 1 | language: mysql 2 | dialect: 5.7 3 | resources: 4 | - "schema/**/*.sql" 5 | queries: 6 | - "src/dao/**/*.ts" 7 | - "src/others/queryUserNameView.ts" 8 | - "!src/**/*.test.ts" 9 | - "!src/**/*.test.integration.ts" 10 | generates: 11 | types: src/generated/fromSql/types.ts 12 | queryFunctions: src/generated/fromSql/queryFunctions.ts 13 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres-noqueries/schema/tables/ingredient.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE ingredient ( 2 | id bigserial PRIMARY KEY, 3 | uuid uuid NOT NULL, 4 | created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 5 | name varchar NOT NULL, 6 | cost numeric(5, 2) NOT NULL, 7 | CONSTRAINT ingredient_pk PRIMARY KEY (id), 8 | CONSTRAINT ingredient_ux1 UNIQUE (name) 9 | ) 10 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/common/__test_assets__/job.insides.sql: -------------------------------------------------------------------------------- 1 | id bigserial NOT NULL, 2 | uuid uuid NOT NULL, 3 | created_at timestamp with time zone NOT NULL DEFAULT now(), 4 | status varchar NOT NULL, 5 | CONSTRAINT job_pk PRIMARY KEY (id), 6 | CONSTRAINT job_ux1 UNIQUE (uuid), 7 | CONSTRAINT job_status_check CHECK (status IN ('QUEUED', 'ATTEMPTED', 'FULFILLED', 'FAILED', 'CANCELED')) 8 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/tables/job.postgres.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE job ( 2 | id bigserial NOT NULL, 3 | uuid uuid NOT NULL, 4 | created_at timestamp with time zone NOT NULL DEFAULT now(), 5 | status varchar NOT NULL, 6 | CONSTRAINT job_pk PRIMARY KEY (id), 7 | CONSTRAINT job_ux1 UNIQUE (uuid), 8 | CONSTRAINT job_status_check CHECK (status IN ('QUEUED', 'ATTEMPTED', 'FULFILLED', 'FAILED', 'CANCELED')) 9 | ); 10 | -------------------------------------------------------------------------------- /jest.unit.env.ts: -------------------------------------------------------------------------------- 1 | import { stage, Stage } from './src/utils/environment'; 2 | 3 | /** 4 | * sanity check that unit tests are only run in 'test' environment 5 | * - if they are run in prod environment, we could load a bunch of junk data into our prod databases, which would be no bueno 6 | */ 7 | if (stage !== Stage.TEST && process.env.I_KNOW_WHAT_IM_DOING !== 'true') 8 | throw new Error(`unit-test is not targeting stage 'test'`); 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src" 5 | }, 6 | "exclude": [ 7 | "dist", 8 | "coverage", 9 | "node_modules", 10 | "acceptance", 11 | "provision", 12 | "jest.*", 13 | "prettier.*", 14 | "commitlint.*", 15 | "codegen.*", 16 | "src/**/*.test.(ts|js)", 17 | "src/**/__test_utils__/**/*.(ts|js)" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/find_users_by_last_name_no_aliases.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | user.id, 3 | CONCAT(user.first_name, " ", user.last_name) as full_name, 4 | user.age, 5 | phone.number as phone_number, 6 | view_user_profile_current.description 7 | FROM user 8 | JOIN phone ON phone.user_id = user.id 9 | JOIN view_user_profile_current ON view_user_profile_current.user_id = user.id 10 | WHERE 1=1 11 | and user.last_name = :lastName; 12 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/select_suggestion.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | s.id, 3 | s.uuid, 4 | s.suggestion_source, 5 | s.external_id, 6 | s.suggested_idea_id, 7 | s.resultant_curated_idea_id, 8 | v.status, 9 | v.result, 10 | s.created_at, 11 | v.effective_at, 12 | v.created_at as updated_at 13 | FROM suggestion s 14 | JOIN suggestion_cvp cvp ON s.id = cvp.suggestion_id 15 | JOIN suggestion_version v ON v.id = cvp.suggestion_version_id; 16 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/views/view_suggestion_current.sql: -------------------------------------------------------------------------------- 1 | CREATE VIEW `view_suggestion_current` AS 2 | SELECT 3 | s.id, 4 | s.uuid, 5 | s.suggestion_source, 6 | s.external_id, 7 | v.status, 8 | v.result, 9 | s.created_at, 10 | v.effective_at, 11 | v.created_at as updated_at 12 | FROM suggestion s 13 | JOIN suggestion_cvp cvp ON s.id = cvp.suggestion_id 14 | JOIN suggestion_version v ON v.id = cvp.suggestion_version_id; 15 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/codegen.sql.yml: -------------------------------------------------------------------------------- 1 | language: postgres 2 | dialect: 10.7 3 | resources: 4 | - 'schema/**/*.sql' 5 | queries: 6 | - 'src/dao/**/*.ts' 7 | - 'src/others/queryUserNameView.ts' 8 | - 'src/others/queryUserWithMatchingIcecream.ts' 9 | - '!src/**/*.test.ts' 10 | - '!src/**/*.test.integration.ts' 11 | generates: 12 | types: src/generated/fromSql/types.ts 13 | queryFunctions: src/generated/fromSql/queryFunctions.ts 14 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/src/dao/iceCream/findById.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = find_ice_cream_by_id 3 | SELECT 4 | s.id, 5 | s.uuid, 6 | s.name, 7 | ( 8 | SELECT array_agg(ice_cream_to_ingredient.ingredient_id ORDER BY ice_cream_to_ingredient.ingredient_id) 9 | FROM ice_cream_to_ingredient WHERE ice_cream_to_ingredient.ice_cream_id = s.id 10 | ) as ingredient_ids, 11 | s.created_at 12 | FROM ice_cream s; 13 | `.trim(); 14 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/src/dao/iceCream/findById.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = find_ice_cream_by_id 3 | SELECT 4 | s.id, 5 | s.uuid, 6 | s.name, 7 | ( 8 | SELECT GROUP_CONCAT(ice_cream_to_ingredient.ingredient_id ORDER BY ice_cream_to_ingredient.ingredient_id) 9 | FROM ice_cream_to_ingredient WHERE ice_cream_to_ingredient.ice_cream_id = s.id 10 | ) as ingredient_ids, 11 | s.created_at 12 | FROM ice_cream s 13 | ; 14 | `.trim(); 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release-please: 10 | runs-on: ubuntu-20.04 11 | steps: 12 | - uses: google-github-actions/release-please-action@v3 13 | with: 14 | token: ${{ secrets.RELEASE_PLEASE_GITHUB_TOKEN }} 15 | release-type: node 16 | pull-request-title-pattern: 'chore(release): v${version} 🎉' 17 | changelog-path: changelog.md 18 | -------------------------------------------------------------------------------- /src/domain/objects/QueryDeclaration.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | const schema = Joi.object().keys({ 5 | path: Joi.string().required(), 6 | sql: Joi.string().required(), 7 | }); 8 | export interface QueryDeclaration { 9 | path: string; 10 | sql: string; 11 | } 12 | export class QueryDeclaration 13 | extends DomainObject 14 | implements QueryDeclaration 15 | { 16 | public static schema = schema; 17 | } 18 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/schema/tables/ingredient.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `ingredient` ( 2 | `id` bigint(20) NOT NULL AUTO_INCREMENT, 3 | `uuid` char(36) COLLATE utf8mb4_bin NOT NULL, 4 | `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 5 | `name` varchar(255) COLLATE utf8mb4_bin NOT NULL, 6 | `cost` decimal(5,2) NOT NULL, 7 | PRIMARY KEY (`id`), 8 | UNIQUE KEY `tag_ux1` (`name`) 9 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin 10 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/schema/tables/ice_cream.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `ice_cream` ( 2 | `id` bigint(20) NOT NULL AUTO_INCREMENT, 3 | `uuid` char(36) COLLATE utf8mb4_bin NOT NULL, 4 | `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 5 | `name` varchar(255) COLLATE utf8mb4_bin NOT NULL, 6 | `ingredient_ids_hash` binary(32) NOT NULL, 7 | PRIMARY KEY (`id`), 8 | UNIQUE KEY `tag_ux1` (`name`) 9 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin 10 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/schema/tables/ice_cream.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `ice_cream` ( 2 | `id` bigint(20) NOT NULL AUTO_INCREMENT, 3 | `uuid` char(36) COLLATE utf8mb4_bin NOT NULL, 4 | `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 5 | `name` varchar(255) COLLATE utf8mb4_bin NOT NULL, 6 | `ingredient_ids_hash` binary(32) NOT NULL, 7 | PRIMARY KEY (`id`), 8 | UNIQUE KEY `tag_ux1` (`name`) 9 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin 10 | -------------------------------------------------------------------------------- /provision/docker/integration_test_db/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | mysql: 4 | build: 5 | context: . 6 | dockerfile: build-image.dockerfile 7 | container_name: superimportantdb_server 8 | command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci 9 | ports: 10 | - 12821:3306 11 | environment: 12 | MYSQL_ROOT_PASSWORD: a-secure-password 13 | MYSQL_DATABASE: superimportantdb # creates the database for us automatically 14 | -------------------------------------------------------------------------------- /src/domain/objects/SqlSubqueryReference.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | const schema = Joi.object().keys({ 5 | id: Joi.string().required(), 6 | sql: Joi.string().required(), 7 | }); 8 | export interface SqlSubqueryReference { 9 | id: string; 10 | sql: string; 11 | } 12 | export class SqlSubqueryReference 13 | extends DomainObject 14 | implements SqlSubqueryReference 15 | { 16 | public static schema = schema; 17 | } 18 | -------------------------------------------------------------------------------- /src/domain/objects/ResourceDeclaration.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | const schema = Joi.object().keys({ 5 | path: Joi.string().required(), 6 | sql: Joi.string().required(), 7 | }); 8 | 9 | export interface ResourceDeclaration { 10 | path: string; 11 | sql: string; 12 | } 13 | export class ResourceDeclaration 14 | extends DomainObject 15 | implements ResourceDeclaration 16 | { 17 | public static schema = schema; 18 | } 19 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres-noqueries/schema/tables/ice_cream.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `ice_cream` ( 2 | `id` bigint(20) NOT NULL AUTO_INCREMENT, 3 | `uuid` char(36) COLLATE utf8mb4_bin NOT NULL, 4 | `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 5 | `name` varchar(255) COLLATE utf8mb4_bin NOT NULL, 6 | `ingredient_ids_hash` binary(32) NOT NULL, 7 | PRIMARY KEY (`id`), 8 | UNIQUE KEY `tag_ux1` (`name`) 9 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin 10 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/__test_assets__/find_with_subselect_in_select_expressions.sql: -------------------------------------------------------------------------------- 1 | -- query_name = find_with_subselect_in_select_expressions 2 | SELECT 3 | s.id, 4 | s.uuid, 5 | s.name, 6 | ( 7 | SELECT GROUP_CONCAT(ice_cream_to_ingredient.ingredient_id ORDER BY ice_cream_to_ingredient.ingredient_id ASC SEPARATOR ',') 8 | FROM ice_cream_to_ingredient WHERE ice_cream_to_ingredient.ice_cream_id = s.id 9 | ) as ingredient_ids, 10 | s.created_at 11 | FROM ice_cream s 12 | ; 13 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} # per [workflow] x [branch, tag] 10 | cancel-in-progress: true # cancel workflows for non-latest commits 11 | 12 | jobs: 13 | test: 14 | uses: ./.github/workflows/.test.yml 15 | 16 | publish: 17 | uses: ./.github/workflows/.publish-npm.yml 18 | needs: [test] 19 | secrets: 20 | npm-auth-token: ${{ secrets.NPM_TOKEN }} 21 | -------------------------------------------------------------------------------- /provision/docker/integration_test_db/wait-for-mysql.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # wait until MySQL is really available 3 | maxcounter=45 4 | 5 | counter=1 6 | while ! mysql --protocol TCP -u"root" -p"$MYSQL_ROOT_PASSWORD" -e "show databases;" > /dev/null 2>&1; do 7 | sleep 1 8 | counter=`expr $counter + 1` 9 | if [ $counter -gt $maxcounter ]; then 10 | >&2 echo "we have been waiting for MySQL too long already; failing" 11 | exit 1 12 | fi; 13 | done 14 | echo "connected to mysql instance successfuly" 15 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/views/view_email_current.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE VIEW view_email_current AS 2 | SELECT 3 | s.id, 4 | s.uuid, 5 | s.external_id, 6 | s.ses_message_id, 7 | s.to_address, 8 | s.from_address, 9 | s.from_name, 10 | s.subject, 11 | s.body, 12 | v.status, 13 | s.created_at, 14 | v.effective_at, 15 | v.created_at as updated_at 16 | FROM email s 17 | JOIN email_cvp cvp ON s.id = cvp.email_id 18 | JOIN email_version v ON v.id = cvp.email_version_id; 19 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/find_all_suggestions_by_normalized_source.sql: -------------------------------------------------------------------------------- 1 | /* 2 | e.g.,: our "suggestion source" has been normalized into another table 3 | 4 | and we have a functino that returns the id given a string, so we dont have to lookup id before insert 5 | */ 6 | SELECT 7 | suggestion.id, 8 | suggestion.uuid, 9 | suggestion.suggested_idea_id 10 | FROM suggestions s 11 | WHERE 1=1 12 | AND s.suggestion_source_id = get_id_from_suggestion_source_name(:name) 13 | AND s.created_at > '2020-01-01 05:55:55'; 14 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractNameFromQuerySql.ts: -------------------------------------------------------------------------------- 1 | export const extractNameFromQuerySql = ({ sql }: { sql: string }) => { 2 | const [ 3 | _, // tslint:disable-line no-unused 4 | queryNameMatch, 5 | ] = new RegExp(/(?:--\squery_name\s?=\s?)([\w_]+)/g).exec(sql) ?? []; 6 | if (!queryNameMatch) { 7 | throw new Error( 8 | 'sql for query does not have name defined. please define the query name with the `-- query_name = your_query_name_here` syntax.', 9 | ); 10 | } 11 | return queryNameMatch.trim(); 12 | }; 13 | -------------------------------------------------------------------------------- /jest.integration.env.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | jest.setTimeout(90000); // since we're calling downstream apis 3 | 4 | /** 5 | * sanity check that unit tests are only run the 'test' environment 6 | * 7 | * usecases 8 | * - prevent polluting prod state with test data 9 | * - prevent executing financially impacting mutations 10 | */ 11 | if ( 12 | (process.env.NODE_ENV !== 'test' || process.env.STAGE) && 13 | process.env.I_KNOW_WHAT_IM_DOING !== 'true' 14 | ) 15 | throw new Error(`integration.test is not targeting stage 'test'`); 16 | -------------------------------------------------------------------------------- /src/logic/config/getConfig/getConfig.ts: -------------------------------------------------------------------------------- 1 | import { GeneratorConfig } from '../../../domain'; 2 | import { readConfig } from './readConfig'; 3 | 4 | /* 5 | 1. read the config 6 | 2. validate the config 7 | */ 8 | export const getConfig = async ({ 9 | configPath, 10 | }: { 11 | configPath: string; 12 | }): Promise => { 13 | // 1. read the config 14 | const config = await readConfig({ filePath: configPath }); 15 | 16 | // 2. validate the config; TODO: 17 | 18 | // 3. return the config, since valid 19 | return config; 20 | }; 21 | -------------------------------------------------------------------------------- /src/contract/__test_assets__/schema/tables/user.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `user` ( 2 | -- meta 3 | `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, 4 | `uuid` VARCHAR(36) NOT NULL, 5 | `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, 6 | 7 | -- static data (in this example... in real life this should not be static) 8 | `first_name` VARCHAR(255) NOT NULL, 9 | `last_name` VARCHAR(255) NOT NULL, 10 | 11 | -- meta meta 12 | UNIQUE (`first_name`, `last_name`) -- (in this example... in real life, users should not actually be unique on their name) 13 | ) ENGINE = InnoDB; 14 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/src/others/queryUserWithMatchingIcecream.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = select_user_with_matching_ice_cream 3 | SELECT 4 | u.first_name, 5 | u.last_name, 6 | ( 7 | SELECT json_build_object( 8 | 'id', ice_cream.id, 9 | 'uuid', ice_cream.uuid, 10 | 'name', ice_cream.name 11 | ) AS json_build_object 12 | FROM ice_cream WHERE ice_cream.id = user.id -- in real life, this would be a weird relationship to be interested in 13 | ) AS ice_cream 14 | FROM user u 15 | WHERE u.id = :userId; 16 | `; 17 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/common/castResourceNameToTypescriptTypeName.test.ts: -------------------------------------------------------------------------------- 1 | import { ResourceType } from '../../../domain'; 2 | import { castResourceNameToTypescriptTypeName } from './castResourceNameToTypescriptTypeName'; 3 | 4 | describe('castResourceNameToInterfaceName', () => { 5 | it('should correctly define resource name for a table', () => { 6 | const name = castResourceNameToTypescriptTypeName({ 7 | name: 'some_awesome_table', 8 | resourceType: ResourceType.TABLE, 9 | }); 10 | expect(name).toEqual('SqlTableSomeAwesomeTable'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/schema/tables/user.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `user` ( 2 | -- meta 3 | `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, 4 | `uuid` VARCHAR(36) NOT NULL, 5 | `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, 6 | 7 | -- static data (in this example... in real life this should not be static) 8 | `first_name` VARCHAR(255) NOT NULL, 9 | `last_name` VARCHAR(255) NOT NULL, 10 | 11 | -- meta meta 12 | UNIQUE (`first_name`, `last_name`) -- (in this example... in real life, users should not actually be unique on their name) 13 | ) ENGINE = InnoDB; 14 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/src/dao/iceCream/findAllNew.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = find_all_new_ice_cream 3 | SELECT 4 | s.id, 5 | s.uuid, 6 | s.name, 7 | ( 8 | SELECT GROUP_CONCAT(ice_cream_to_ingredient.ingredient_id ORDER BY ice_cream_to_ingredient.ingredient_id) 9 | FROM ice_cream_to_ingredient WHERE ice_cream_to_ingredient.ice_cream_id = s.id 10 | ) as ingredient_ids, 11 | s.created_at 12 | FROM ice_cream s 13 | WHERE s.created_at > DATE_SUB(NOW(), INTERVAL 7 day) -- new if less than 7 days old 14 | LIMIT :limit 15 | ; 16 | `.trim(); 17 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/src/dao/iceCream/findAllNew.ts: -------------------------------------------------------------------------------- 1 | export const sql = ` 2 | -- query_name = find_all_new_ice_cream 3 | SELECT 4 | s.id, 5 | s.uuid, 6 | s.name, 7 | ( 8 | SELECT array_agg(ice_cream_to_ingredient.ingredient_id ORDER BY ice_cream_to_ingredient.ingredient_id) 9 | FROM ice_cream_to_ingredient WHERE ice_cream_to_ingredient.ice_cream_id = s.id 10 | ) as ingredient_ids, 11 | s.created_at 12 | FROM ice_cream s 13 | WHERE s.created_at > DATE_SUB(NOW(), INTERVAL 7 day) -- new if less than 7 days old 14 | LIMIT :limit 15 | ; 16 | `.trim(); 17 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/find_job_by_id.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | s.id, 3 | s.uuid, 4 | s.title, 5 | s.status, 6 | s.description, 7 | v.location_id, 8 | ( 9 | SELECT array_agg(job_version_to_photo.photo_id ORDER BY job_version_to_photo.array_order_index) as array_agg 10 | FROM job_version_to_photo WHERE job_version_to_photo.job_version_id = v.id 11 | ) as photo_ids, 12 | s.created_at, 13 | v.effective_at, 14 | v.created_at as updated_at 15 | FROM job s 16 | JOIN job_cvp cvp ON s.id = cvp.job_id 17 | JOIN job_version v ON v.id = cvp.job_version_id 18 | WHERE s.id = :id; 19 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/common/castResourceNameToTypescriptTypeName.ts: -------------------------------------------------------------------------------- 1 | import { pascalCase } from 'pascal-case'; 2 | 3 | import { ResourceType } from '../../../domain'; 4 | 5 | export const castResourceNameToTypescriptTypeName = ({ 6 | name, 7 | resourceType, 8 | }: { 9 | name: string; 10 | resourceType: ResourceType; 11 | }) => { 12 | const resourceTypeInTitleCase = 13 | resourceType[0]!.toUpperCase() + resourceType.substr(1).toLowerCase(); 14 | const resourceNameInPascalCase = pascalCase(name); 15 | return `Sql${resourceTypeInTitleCase}${resourceNameInPascalCase}`; 16 | }; 17 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/schema/tables/user.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE user ( 2 | -- meta 3 | id BIGSERIAL PRIMARY KEY, 4 | uuid UUID NOT NULL, 5 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 6 | 7 | -- static data (in this example... in real life this should not be static) 8 | first_name VARCHAR NOT NULL, 9 | last_name VARCHAR NOT NULL, 10 | 11 | -- meta meta 12 | CONSTRAINT user_ux1 UNIQUE (first_name, last_name) -- (in this example... in real life, users should not actually be unique on their name) 13 | ); 14 | CREATE INDEX user_ix ON user USING btree (last_name); 15 | -------------------------------------------------------------------------------- /src/logic/commands/generate/defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions/QueryFunctionsOutputPathNotDefinedError.ts: -------------------------------------------------------------------------------- 1 | export class QueryFunctionsOutputPathNotDefinedButRequiredError extends Error { 2 | constructor() { 3 | super( 4 | `Query Functions output path was not defined in config (config.generated.queryFunctions) but is required. 5 | 6 | This code path should not have been run as omitting the output path in the config is a signal that the user does not want QueryFunctions defined. Therefore, this is an unexpected error from the library. 7 | `, 8 | ); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractInputVariablesFromQuerySql/extractInputVariableTokensFromQuerySql.ts: -------------------------------------------------------------------------------- 1 | const EXTRACT_INPUT_VARIABLES_REGEX = /(?:[^\w:])(\:\w+)/g; 2 | 3 | export const extractInputVariableTokensFromQuerySql = ({ 4 | sql, 5 | }: { 6 | sql: string; 7 | }) => { 8 | const regex = new RegExp(EXTRACT_INPUT_VARIABLES_REGEX); 9 | const regexMatches = sql.match(regex) ?? []; 10 | const inputVariableTokens = regexMatches.map((str) => str.slice(1)); // drop the leading non-word character (TODO: find how to "match" without including that char) 11 | return inputVariableTokens; 12 | }; 13 | -------------------------------------------------------------------------------- /src/domain/objects/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GeneratorConfig'; 2 | export * from './QueryDeclaration'; 3 | export * from './ResourceDeclaration'; 4 | export * from './TypeDefinitionOfResourceColumn'; 5 | export * from './TypeDefinitionOfResourceTable'; 6 | export * from './TypeDefinitionOfResourceFunction'; 7 | export * from './TypeDefinitionOfResourceInput'; 8 | export * from './TypeDefinitionOfResourceView'; 9 | export * from './TypeDefinitionOfQuery'; 10 | export * from './TypeDefinitionOfQuerySelectExpression'; 11 | export * from './TypeDefinitionOfQueryTableReference'; 12 | export * from './TypeDefinitionReference'; 13 | -------------------------------------------------------------------------------- /.github/workflows/review.yml: -------------------------------------------------------------------------------- 1 | name: review 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | pull-requests: read 12 | 13 | jobs: 14 | pullreq-title: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: test:pullreq:title 18 | uses: amannn/action-semantic-pull-request@v5 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | with: 22 | # https://github.com/commitizen/conventional-commit-types 23 | types: | 24 | fix 25 | feat 26 | chore 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | workflow_call: 5 | push: 6 | branches-ignore: 7 | - 'main' # exclude main branch, since deploy workflow triggers on main, and calls the test workflow inside of it already 8 | tags-ignore: 9 | - v* # exclude tags, since deploy workflow triggers on tags, and calls the test workflow inside of it already 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} # per [workflow] x [branch, tag] 13 | cancel-in-progress: true # cancel workflows for non-latest commits 14 | 15 | jobs: 16 | suite: 17 | uses: ./.github/workflows/.test.yml 18 | -------------------------------------------------------------------------------- /src/contract/__test_assets__/schema/tables/home.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `home` ( 2 | -- meta 3 | `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, 4 | `uuid` VARCHAR(36) NOT NULL, 5 | `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, 6 | 7 | -- static data (in this example... in real life this should not be static) 8 | `address` VARCHAR(255) NOT NULL, -- in this example, an address is just a string 9 | `user_id` VARCHAR(255) NOT NULL, -- in this example, homes can never change hands 10 | 11 | -- meta meta 12 | UNIQUE (`address`), 13 | CONSTRAINT home_fk_1 FOREIGN KEY (user_id) REFERENCES user(id) 14 | ) ENGINE = InnoDB; 15 | -------------------------------------------------------------------------------- /src/contract/commands/generate.integration.test.ts: -------------------------------------------------------------------------------- 1 | import SqlCodeGenerator from './generate'; 2 | 3 | describe('generate', () => { 4 | it('should be able to generate code for valid config and sql, generating both types and query functions', async () => { 5 | await SqlCodeGenerator.run([ 6 | '-c', 7 | `${__dirname}/../__test_assets__/codegen.sql.yml`, 8 | ]); 9 | }); 10 | it('should be able to generate code for valid config and sql, only generating types', async () => { 11 | await SqlCodeGenerator.run([ 12 | '-c', 13 | `${__dirname}/../__test_assets__/codegen.sql.only-types.yml`, 14 | ]); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/find_all_chat_messages_by_thread.sql: -------------------------------------------------------------------------------- 1 | -- query_name = find_all_chat_message_by_thread 2 | SELECT 3 | chat_message.id, 4 | chat_message.uuid, 5 | ( 6 | SELECT chat_thread.uuid 7 | FROM chat_thread WHERE chat_thread.id = chat_message.thread_id 8 | ) AS thread_uuid, 9 | chat_message.sent_at, 10 | chat_message.text 11 | FROM view_chat_message_current AS chat_message 12 | WHERE 1=1 13 | AND chat_message.thread_id = (SELECT id FROM chat_thread WHERE chat_thread.uuid = :threadUuid) 14 | ORDER BY chat_message.sent_at DESC -- latest first 15 | LIMIT :limit 16 | OFFSET :offset; 17 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/views/view_job_current.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE VIEW view_job_current AS 2 | SELECT 3 | s.id, 4 | s.uuid, 5 | s.title, 6 | s.status, 7 | s.description, 8 | v.location_id, 9 | ( 10 | SELECT array_agg(job_version_to_photo.photo_id ORDER BY job_version_to_photo.array_order_index) as array_agg 11 | FROM job_version_to_photo WHERE job_version_to_photo.job_version_id = v.id 12 | ) as photo_ids, 13 | s.created_at, 14 | v.effective_at, 15 | v.created_at as updated_at 16 | FROM job s 17 | JOIN job_cvp cvp ON s.id = cvp.job_id 18 | JOIN job_version v ON v.id = cvp.job_version_id; 19 | -------------------------------------------------------------------------------- /src/logic/config/getConfig/readConfig/utils/readYmlFile.ts: -------------------------------------------------------------------------------- 1 | import YAML from 'yaml'; 2 | 3 | import { readFileAsync } from '../../../../common/readFileAsync'; 4 | 5 | export const readYmlFile = async ({ filePath }: { filePath: string }) => { 6 | // check path is for yml file 7 | if (filePath.slice(-4) !== '.yml') 8 | throw new Error(`file path point to a .yml file. error: ${filePath}`); 9 | 10 | // get file contents 11 | const stringContent = await readFileAsync({ filePath }); 12 | 13 | // parse the string content into yml 14 | const content = YAML.parse(stringContent); 15 | 16 | // return the content 17 | return content; 18 | }; 19 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/tables/image.mysql.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `image` ( 2 | `id` bigint(20) NOT NULL AUTO_INCREMENT, 3 | `uuid` char(36) COLLATE utf8mb4_bin NOT NULL, 4 | `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 5 | `url` varchar(190) COLLATE utf8mb4_bin NOT NULL, 6 | `caption` varchar(190) COLLATE utf8mb4_bin DEFAULT NULL, 7 | `credit` varchar(190) COLLATE utf8mb4_bin, -- note: we strip "DEFAULT NULL" here for testing 8 | `alt_text` varchar(190) COLLATE utf8mb4_bin, 9 | PRIMARY KEY (`id`), 10 | UNIQUE KEY `image_ux1` (`url`,`caption`,`credit`,`alt_text`) 11 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin 12 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/common/__test_assets__/suggestion_version.insides.sql: -------------------------------------------------------------------------------- 1 | `id` bigint(20) NOT NULL AUTO_INCREMENT, 2 | `suggestion_id` bigint(20) NOT NULL, 3 | `effective_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 4 | `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 5 | `status` enum('PENDING','REVIEWED') COLLATE utf8mb4_bin NOT NULL, 6 | PRIMARY KEY (`id`), 7 | UNIQUE KEY `suggestion_version_ux1` (`suggestion_id`,`effective_at`,`created_at`), 8 | KEY `suggestion_version_fk0` (`suggestion_id`), 9 | CONSTRAINT `suggestion_version_fk0` FOREIGN KEY (`suggestion_id`) REFERENCES `suggestion` (`id`) 10 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/schema/tables/image.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `image` ( 2 | `id` bigint(20) NOT NULL AUTO_INCREMENT, 3 | `uuid` char(36) COLLATE utf8mb4_bin NOT NULL, 4 | `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 5 | `url` varchar(190) COLLATE utf8mb4_bin NOT NULL, 6 | `caption` varchar(190) COLLATE utf8mb4_bin DEFAULT NULL, 7 | `credit` varchar(190) COLLATE utf8mb4_bin, -- note: we strip "DEFAULT NULL" here for testing 8 | `alt_text` varchar(190) COLLATE utf8mb4_bin, 9 | PRIMARY KEY (`id`), 10 | UNIQUE KEY `image_ux1` (`url`,`caption`,`credit`,`alt_text`) 11 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node-lts-strictest/tsconfig.json", 3 | "compilerOptions": { 4 | "importsNotUsedAsValues": "remove", 5 | "noPropertyAccessFromIndexSignature": false, 6 | "noImplicitOverride": false, 7 | "resolveJsonModule": true, 8 | "useUnknownInCatchVariables": false, 9 | "exactOptionalPropertyTypes": false, 10 | "declaration": true, 11 | "sourceMap": true, 12 | "outDir": "dist", 13 | "target": "es6", 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false 16 | }, 17 | "include": [ 18 | "**/*.ts" 19 | ], 20 | "exclude": [ 21 | "dist", 22 | "coverage", 23 | "node_modules" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/query/__snapshots__/defineTypescriptQueryFunctionForQuery.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`defineTypescriptQueryFunctionForQuery should define the method accurately based on name and import path 1`] = ` 4 | "export const sqlQueryUpsertImage = async ({ 5 | dbExecute, 6 | logDebug, 7 | input, 8 | }: { 9 | dbExecute: DatabaseExecuteCommand; 10 | logDebug: LogMethod; 11 | input: SqlQueryUpsertImageInput; 12 | }): Promise => 13 | executeQueryWithBestPractices({ 14 | dbExecute, 15 | logDebug, 16 | name: 'sqlQueryUpsertImage', 17 | sql: sqlQueryUpsertImageSql, 18 | input, 19 | });" 20 | `; 21 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/getTypeDefinitionFromQueryDeclaration.ts: -------------------------------------------------------------------------------- 1 | import { QueryDeclaration } from '../../../domain'; 2 | import { extractNameFromQuerySql } from './extractNameFromQuerySql'; 3 | import { extractTypeDefinitionFromQuerySql } from './extractTypeDefinitionFromQuerySql'; 4 | 5 | export const getTypeDefinitionFromQueryDeclaration = ({ 6 | declaration, 7 | }: { 8 | declaration: QueryDeclaration; 9 | }) => { 10 | // 1. get the name of the query 11 | const name = extractNameFromQuerySql({ sql: declaration.sql }); 12 | 13 | // 2. get the type def 14 | return extractTypeDefinitionFromQuerySql({ 15 | name, 16 | path: declaration.path, 17 | sql: declaration.sql, 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/find_all_chat_messages_by_thread.lessthan_or_equalsto_or_null.sql: -------------------------------------------------------------------------------- 1 | -- query_name = find_all_chat_message_by_thread 2 | SELECT 3 | chat_message.id, 4 | chat_message.uuid, 5 | ( 6 | SELECT chat_thread.uuid 7 | FROM chat_thread WHERE chat_thread.id = chat_message.thread_id 8 | ) AS thread_uuid, 9 | chat_message.sent_at, 10 | chat_message.text 11 | FROM view_chat_message_current AS chat_message 12 | WHERE 1=1 13 | AND chat_message.thread_id = (SELECT id FROM chat_thread WHERE chat_thread.uuid = :threadUuid) 14 | AND (:until is null OR chat_message.created_at <= :until) 15 | ORDER BY chat_message.sent_at DESC -- latest first 16 | LIMIT :limit; 17 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/tables/suggestion_version.mysql.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `suggestion_version` ( 2 | `id` bigint(20) NOT NULL AUTO_INCREMENT, 3 | `suggestion_id` bigint(20) NOT NULL, 4 | `effective_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 5 | `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 6 | `status` enum('PENDING','REVIEWED') COLLATE utf8mb4_bin NOT NULL, 7 | PRIMARY KEY (`id`), 8 | UNIQUE KEY `suggestion_version_ux1` (`suggestion_id`,`effective_at`,`created_at`), 9 | KEY `suggestion_version_fk0` (`suggestion_id`), 10 | CONSTRAINT `suggestion_version_fk0` FOREIGN KEY (`suggestion_id`) REFERENCES `suggestion` (`id`) 11 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin 12 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/schema/tables/communication_channel.sql: -------------------------------------------------------------------------------- 1 | -- ---------------------------------------- 2 | -- create communication channel table 3 | -- ---------------------------------------- 4 | CREATE TABLE `communication_channel` ( 5 | -- meta 6 | `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, 7 | `uuid` VARCHAR(36) NOT NULL, 8 | `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, 9 | 10 | -- static data 11 | `type` ENUM('APP', 'SMS', 'EMAIL') NOT NULL, -- e.g., "how do we send this" 12 | `address` VARCHAR(36) NOT NULL, -- e.g., "where do we send this" 13 | 14 | -- meta meta 15 | UNIQUE (`type`, `address`) -- type + address uniquely identify a communication channel 16 | ) ENGINE = InnoDB; 17 | -------------------------------------------------------------------------------- /jest.acceptance.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | // ensure tests run in utc, like they will on cicd and on server; https://stackoverflow.com/a/56277249/15593329 4 | process.env.TZ = 'UTC'; 5 | 6 | // https://jestjs.io/docs/configuration 7 | const config: Config = { 8 | verbose: true, 9 | testEnvironment: 'node', 10 | moduleFileExtensions: ['js', 'ts'], 11 | transform: { 12 | '^.+\\.tsx?$': 'ts-jest', // https://kulshekhar.github.io/ts-jest/docs/getting-started/presets 13 | }, 14 | testMatch: ['**/*.acceptance.test.ts'], 15 | setupFiles: ['core-js'], 16 | setupFilesAfterEnv: ['./jest.acceptance.env.ts'], 17 | }; 18 | 19 | // eslint-disable-next-line import/no-default-export 20 | export default config; 21 | -------------------------------------------------------------------------------- /jest.integration.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | // ensure tests run in utc, like they will on cicd and on server; https://stackoverflow.com/a/56277249/15593329 4 | process.env.TZ = 'UTC'; 5 | 6 | // https://jestjs.io/docs/configuration 7 | const config: Config = { 8 | verbose: true, 9 | testEnvironment: 'node', 10 | moduleFileExtensions: ['js', 'ts'], 11 | transform: { 12 | '^.+\\.tsx?$': 'ts-jest', // https://kulshekhar.github.io/ts-jest/docs/getting-started/presets 13 | }, 14 | testMatch: ['**/*.integration.test.ts'], 15 | setupFiles: ['core-js'], 16 | setupFilesAfterEnv: ['./jest.integration.env.ts'], 17 | }; 18 | 19 | // eslint-disable-next-line import/no-default-export 20 | export default config; 21 | -------------------------------------------------------------------------------- /src/domain/objects/TypeDefinitionOfQuerySelectExpression.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | import { TypeDefinitionReference } from './TypeDefinitionReference'; 5 | 6 | const schema = Joi.object().keys({ 7 | alias: Joi.string().required(), // e.g., "v.id as version_id" => "version_id" 8 | typeReference: TypeDefinitionReference.schema.required(), 9 | }); 10 | export interface TypeDefinitionOfQuerySelectExpression { 11 | alias: string; 12 | typeReference: TypeDefinitionReference; 13 | } 14 | export class TypeDefinitionOfQuerySelectExpression 15 | extends DomainObject 16 | implements TypeDefinitionOfQuerySelectExpression 17 | { 18 | public static schema = schema; 19 | } 20 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/tables/job_version.postgres.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE job_version ( 2 | id bigserial NOT NULL, 3 | job_id bigint NOT NULL, 4 | effective_at timestamp with time zone NOT NULL DEFAULT now(), 5 | created_at timestamp with time zone NOT NULL DEFAULT now(), 6 | location_id bigint NOT NULL, 7 | photo_ids_hash bytea NOT NULL, 8 | CONSTRAINT job_version_pk PRIMARY KEY (id), 9 | CONSTRAINT job_version_ux1 UNIQUE (job_id, effective_at, created_at), 10 | CONSTRAINT job_version_fk0 FOREIGN KEY (job_id) REFERENCES job (id), 11 | CONSTRAINT job_version_fk1 FOREIGN KEY (location_id) REFERENCES location (id) 12 | ); 13 | CREATE INDEX job_version_fk0_ix ON job_version USING btree (job_id); 14 | CREATE INDEX job_version_fk1_ix ON job_version USING btree (location_id); 15 | -------------------------------------------------------------------------------- /src/logic/commands/generate/defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions/utils/getRelativePathFromFileToFile.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export const getRelativePathFromFileToFile = ({ 4 | fromFile, 5 | toFile, 6 | }: { 7 | fromFile: string; 8 | toFile: string; 9 | }) => { 10 | let relativePath = path.relative(fromFile, toFile); 11 | 12 | // the module always adds an extra step up. remove it 13 | relativePath = relativePath.replace(/^..\//, ''); 14 | if (relativePath[0] !== '.') relativePath = `./${relativePath}`; // if it was in same dir, then add the "same dir" spec 15 | 16 | // drop the extension 17 | relativePath = relativePath.split('.').slice(0, -1).join('.'); 18 | 19 | // return the cleaned up path 20 | return relativePath; 21 | }; 22 | -------------------------------------------------------------------------------- /src/domain/objects/TypeDefinitionOfResourceInput.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | import { DataType } from '../constants'; 5 | 6 | /* 7 | key value map of any key string -> data type[] (we join the array into a union) 8 | */ 9 | const schema = Joi.object().keys({ 10 | name: Joi.string().required(), 11 | type: Joi.array().items( 12 | Joi.string() 13 | .valid(...Object.values(DataType)) 14 | .required(), 15 | ), 16 | }); 17 | export interface TypeDefinitionOfResourceInput { 18 | name: string; 19 | type: DataType[]; 20 | } 21 | export class TypeDefinitionOfResourceInput 22 | extends DomainObject 23 | implements TypeDefinitionOfResourceInput 24 | { 25 | public static schema = schema; 26 | } 27 | -------------------------------------------------------------------------------- /src/domain/objects/TypeDefinitionOfResourceColumn.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | import { DataType } from '../constants'; 5 | 6 | /* 7 | key value map of any key string -> data type[] (we join the array into a union) 8 | */ 9 | const schema = Joi.object().keys({ 10 | name: Joi.string().required(), 11 | type: Joi.array().items( 12 | Joi.string() 13 | .valid(...Object.values(DataType)) 14 | .required(), 15 | ), 16 | }); 17 | export interface TypeDefinitionOfResourceColumn { 18 | name: string; 19 | type: DataType[]; 20 | } 21 | export class TypeDefinitionOfResourceColumn 22 | extends DomainObject 23 | implements TypeDefinitionOfResourceColumn 24 | { 25 | public static schema = schema; 26 | } 27 | -------------------------------------------------------------------------------- /src/domain/objects/TypeDefinitionOfResourceTable.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | import { TypeDefinitionOfResourceColumn } from './TypeDefinitionOfResourceColumn'; 5 | 6 | /* 7 | key value map of any key string -> data type[] (we join the array into a union) 8 | */ 9 | const schema = Joi.object().keys({ 10 | name: Joi.string().required(), 11 | columns: Joi.array().items(TypeDefinitionOfResourceColumn.schema).required(), 12 | }); 13 | export interface TypeDefinitionOfResourceTable { 14 | name: string; 15 | columns: TypeDefinitionOfResourceColumn[]; 16 | } 17 | export class TypeDefinitionOfResourceTable 18 | extends DomainObject 19 | implements TypeDefinitionOfResourceTable 20 | { 21 | public static schema = schema; 22 | } 23 | -------------------------------------------------------------------------------- /src/logic/common/extractSqlFromFile/__snapshots__/extractSqlFromTsFile.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`extractSqlFromTsFile should extract sql from a file that only has sql exported 1`] = ` 4 | " 5 | select 6 | u.id, 7 | concat(first_name, last_name) as full_name 8 | from user u 9 | where 1=1 10 | and name = :name; 11 | " 12 | `; 13 | 14 | exports[`extractSqlFromTsFile should extract sql that exports the sql and imports and exports other things 1`] = ` 15 | " 16 | -- query_name = find_suggestion_by_id 17 | select 18 | s.id, 19 | s.uuid, 20 | s.suggestion_source, 21 | s.external_id, 22 | s.suggested_idea_id, 23 | s.status, 24 | s.result, 25 | s.resultant_curated_idea_id 26 | from view_suggestion_current s 27 | where s.id = :id 28 | " 29 | `; 30 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/flattenSqlByReferencingAndTokenizingSubqueries/__snapshots__/breakSqlIntoNestedSqlArraysAtParentheses.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`breakSqlIntoNestedSqlArraysAtParentheses should accurately break up this example 1`] = ` 4 | [ 5 | "-- query_name = find_with_subselect_in_select_expressions 6 | SELECT 7 | s.id, 8 | s.uuid, 9 | s.name, 10 | ", 11 | [ 12 | "( 13 | SELECT GROUP_CONCAT", 14 | [ 15 | "(ice_cream_to_ingredient.ingredient_id ORDER BY ice_cream_to_ingredient.ingredient_id ASC SEPARATOR ',')", 16 | ], 17 | " 18 | FROM ice_cream_to_ingredient WHERE ice_cream_to_ingredient.ice_cream_id = s.id 19 | )", 20 | ], 21 | " as ingredient_ids, 22 | s.created_at 23 | FROM ice_cream s 24 | ; 25 | ", 26 | ] 27 | `; 28 | -------------------------------------------------------------------------------- /jest.unit.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | // ensure tests run in utc, like they will on cicd and on server; https://stackoverflow.com/a/56277249/15593329 4 | process.env.TZ = 'UTC'; 5 | 6 | // https://jestjs.io/docs/configuration 7 | const config: Config = { 8 | verbose: true, 9 | testEnvironment: 'node', 10 | moduleFileExtensions: ['js', 'ts'], 11 | transform: { 12 | '^.+\\.tsx?$': 'ts-jest', // https://kulshekhar.github.io/ts-jest/docs/getting-started/presets 13 | }, 14 | testMatch: [ 15 | // note: order matters 16 | '**/*.test.ts', 17 | '!**/*.acceptance.test.ts', 18 | '!**/*.integration.test.ts', 19 | ], 20 | setupFiles: ['core-js'], 21 | setupFilesAfterEnv: ['./jest.unit.env.ts'], 22 | }; 23 | 24 | // eslint-disable-next-line import/no-default-export 25 | export default config; 26 | -------------------------------------------------------------------------------- /src/logic/config/getConfig/getConfig.test.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from './getConfig'; 2 | import { readConfig } from './readConfig'; 3 | 4 | jest.mock('./readConfig'); 5 | const readConfigMock = readConfig as jest.Mock; 6 | 7 | describe('getConfig', () => { 8 | beforeEach(() => jest.clearAllMocks()); 9 | it('reads the config from a filepath', async () => { 10 | await getConfig({ configPath: '__CONFIG_PATH__' }); 11 | expect(readConfigMock).toHaveBeenCalledTimes(1); 12 | expect(readConfigMock).toHaveBeenCalledWith({ 13 | filePath: '__CONFIG_PATH__', 14 | }); 15 | }); 16 | it('returns the config', async () => { 17 | readConfigMock.mockResolvedValueOnce('__CONFIG_OBJECT__'); 18 | const result = await getConfig({ configPath: '__CONFIG_PATH__' }); 19 | expect(result).toEqual('__CONFIG_OBJECT__'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/query/defineTypescriptQueryFunctionForQuery.test.ts: -------------------------------------------------------------------------------- 1 | import { defineTypescriptQueryFunctionForQuery } from './defineTypescriptQueryFunctionForQuery'; 2 | 3 | describe('defineTypescriptQueryFunctionForQuery', () => { 4 | it('should define the method accurately based on name and import path', () => { 5 | const { code, imports } = defineTypescriptQueryFunctionForQuery({ 6 | name: 'upsert_image', 7 | }); 8 | expect(imports.generatedTypes.length).toEqual(2); 9 | expect(imports.generatedTypes).toEqual([ 10 | 'SqlQueryUpsertImageInput', 11 | 'SqlQueryUpsertImageOutput', 12 | ]); 13 | expect(imports.queryNameAlias).toEqual('sqlQueryUpsertImageSql'); // i.e., import { query as sqlQueryUpsertImageSql } from '../rel/path/to/query'; 14 | expect(code).toMatchSnapshot(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/domain/objects/TypeDefinitionReference.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | const schema = Joi.object().keys({ 5 | tableReferencePath: Joi.string().required().allow(null), 6 | functionReferencePath: Joi.string().required().allow(null), 7 | }); 8 | export interface TypeDefinitionReference { 9 | /** 10 | * e.g., "i.externalId", if references a table defined in "tableReference"s for the query w/ alias i 11 | */ 12 | tableReferencePath: string | null; 13 | 14 | /** 15 | * e.g., "upsert_image.0" if references first arg of function "upsert_image" 16 | */ 17 | functionReferencePath: string | null; 18 | } 19 | export class TypeDefinitionReference 20 | extends DomainObject 21 | implements TypeDefinitionReference 22 | { 23 | public static schema = schema; 24 | } 25 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/getTypeDefinitionFromDeclaration.ts: -------------------------------------------------------------------------------- 1 | import { QueryDeclaration, ResourceDeclaration } from '../../domain'; 2 | import { getTypeDefinitionFromQueryDeclaration } from './query/getTypeDefinitionFromQueryDeclaration'; 3 | import { getTypeDefinitionFromResourceDeclaration } from './resource/getTypeDefinitionFromResourceDeclaration'; 4 | 5 | export const getTypeDefinitionFromDeclaration = ({ 6 | declaration, 7 | }: { 8 | declaration: QueryDeclaration | ResourceDeclaration; 9 | }) => { 10 | if (declaration instanceof QueryDeclaration) 11 | return getTypeDefinitionFromQueryDeclaration({ declaration }); 12 | if (declaration instanceof ResourceDeclaration) 13 | return getTypeDefinitionFromResourceDeclaration({ declaration }); 14 | throw new Error('unexpected declaration type'); // fail fast, this should never occur 15 | }; 16 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractTableReferencesFromQuerySql/tryToExtractTableReferenceSqlSetFromQuerySql.ts: -------------------------------------------------------------------------------- 1 | import { extractTableReferenceSqlSetFromQuerySql } from './extractTableReferenceSqlSetFromQuerySql'; 2 | 3 | /** 4 | * just wrap the extractTableRef... function in a try catch, that permits special error cases to be allowed 5 | * - e.g., to support queries which select from a function => have no table references 6 | */ 7 | export const tryToExtractTableReferenceSqlSet = ({ sql }: { sql: string }) => { 8 | try { 9 | return extractTableReferenceSqlSetFromQuerySql({ sql }); 10 | } catch (error) { 11 | if (error.message === 'no "from" keyword found') return []; // if no from keyword was found, it may just be a "select function_call()" query; no table references 12 | throw error; // otherwise, error was unexpected 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/throwErrorIfTableReferencePathImpliesTable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * instead of "select id from user" we're asking the user to "select user.id from user" or "select u.id from user u" 3 | * 4 | * rationale: 5 | * - this makes it so that we don't have to try and figure out the implicit reference 6 | * - this makes it so that when a user does join to another table, they don't have to add the explicit references after the fact (i.e., makes it easier to extend their queries) 7 | */ 8 | export const throwErrorIfTableReferencePathImpliesTable = ({ 9 | referencePath, 10 | }: { 11 | referencePath: string; 12 | }) => { 13 | if (referencePath.split('.').length !== 2) { 14 | throw new Error( 15 | `table reference of "${referencePath}" does not have the table alias explicitly defined, violating best practice`, 16 | ); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/logic/commands/generate/defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions/defineTypescriptFunctionCodeForQueryFunctions.ts: -------------------------------------------------------------------------------- 1 | import { TypeDefinitionOfQuery } from '../../../../domain'; 2 | import { defineTypescriptQueryFunctionForQuery } from '../../../typeDefinitionsToCode/query/defineTypescriptQueryFunctionForQuery'; 3 | 4 | export const defineTypescriptFunctionCodeForQueryFunctions = ({ 5 | queryDefinitions, 6 | }: { 7 | queryDefinitions: TypeDefinitionOfQuery[]; 8 | }) => { 9 | return queryDefinitions 10 | .sort((a, b) => (a.name < b.name ? -1 : 1)) // sort for determinism 11 | .map((definition) => { 12 | const { code } = defineTypescriptQueryFunctionForQuery({ 13 | name: definition.name, 14 | }); 15 | return ` 16 | // client method for query '${definition.name}' 17 | ${code} 18 | `.trim(); 19 | }) 20 | .join('\n\n'); 21 | }; 22 | -------------------------------------------------------------------------------- /src/logic/commands/generate/defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions/defineTypescriptCommonExportsForQueryFunctions.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseLanguage } from '../../../../domain'; 2 | 3 | const LANGUAGE_TO_EXECUTE_RETURN_TYPE: { [index in DatabaseLanguage]: string } = 4 | { 5 | [DatabaseLanguage.MYSQL]: 'any[]', // client.query() => [[],...] 6 | [DatabaseLanguage.POSTGRES]: '{ rows: any[] }', // client.query => { rows: any[], oid, ... } 7 | }; 8 | 9 | export const defineTypescriptCommonExportsForQueryFunctions = ({ 10 | language, 11 | }: { 12 | language: DatabaseLanguage; 13 | }) => { 14 | return ` 15 | // typedefs common to each query function 16 | export type DatabaseExecuteCommand = (args: { sql: string; values: any[] }) => Promise<${LANGUAGE_TO_EXECUTE_RETURN_TYPE[language]}>; 17 | export type LogMethod = (message: string, metadata: any) => void; 18 | `.trim(); 19 | }; 20 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/functions/hash_string.mysql.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION `get_from_delimiter_split_string`( 2 | in_array varchar(255), 3 | in_delimiter char(1), 4 | in_index int 5 | ) RETURNS varchar(255) CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci 6 | BEGIN 7 | /* 8 | usage: 9 | SELECT get_from_delimiter_split_string('1,5,3,7,4', ',', 1); -- returns '5' 10 | SELECT get_from_delimiter_split_string('1,5,3,7,4', ',', 10); -- returns '' 11 | */ 12 | return REPLACE( -- remove the delimiters after doing the following: 13 | SUBSTRING( -- pick the string 14 | SUBSTRING_INDEX(in_array, in_delimiter, in_index + 1), -- from the string up to index+1 counts of the delimiter 15 | LENGTH( 16 | SUBSTRING_INDEX(in_array, in_delimiter, in_index) -- keeping only everything after index counts of the delimiter 17 | ) + 1 18 | ), 19 | in_delimiter, 20 | '' 21 | ); 22 | END 23 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/function/extractTypeDefinitionFromFunctionSql.ts: -------------------------------------------------------------------------------- 1 | import { TypeDefinitionOfResourceFunction } from '../../../../domain/objects/TypeDefinitionOfResourceFunction'; 2 | import { extractInputsFromFunctionSql } from './extractInputsFromFunctionSql/extractInputsFromFunctionSql'; 3 | import { extractOutputFromFunctionSql } from './extractOutputFromFunctionSql/extractOutputFromFunctionSql'; 4 | 5 | export const extractTypeDefinitionFromFunctionSql = ({ 6 | name, 7 | sql, 8 | }: { 9 | name: string; 10 | sql: string; 11 | }) => { 12 | // 1. get the input definition 13 | const inputs = extractInputsFromFunctionSql({ sql }); 14 | 15 | // 2. get the output definition 16 | const output = extractOutputFromFunctionSql({ sql }); 17 | 18 | // 3. return the full type 19 | return new TypeDefinitionOfResourceFunction({ 20 | name, 21 | inputs, 22 | output, 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /src/domain/objects/TypeDefinitionOfQueryTableReference.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | const schema = Joi.object().keys({ 5 | alias: Joi.string().required(), // e.g., "user u" => "u" or "user as u" => "u" or "upsert_job(...) as dgv" => "dgv" 6 | tableName: Joi.string().required().allow(null), // e.g., "user" 7 | functionName: Joi.string().required().allow(null), // e.g., "upsert_job" 8 | }); 9 | export interface TypeDefinitionOfQueryTableReference { 10 | alias: string; 11 | tableName: string | null; // not null when table refers to a persisted table 12 | functionName: string | null; // not null when table refers to the output of a function 13 | } 14 | export class TypeDefinitionOfQueryTableReference 15 | extends DomainObject 16 | implements TypeDefinitionOfQueryTableReference 17 | { 18 | public static schema = schema; 19 | } 20 | -------------------------------------------------------------------------------- /src/logic/common/extractSqlFromFile/extractSqlFromTsFile.ts: -------------------------------------------------------------------------------- 1 | import { readFileAsync } from '../readFileAsync'; 2 | 3 | /* 4 | note: in order to not block users when they have typescript type errors (by using ts-node), 5 | we can simply parse the file contents and grab the `export const sql = `__SQL__`;` w/ regex 6 | */ 7 | export const extractSqlFromTsFile = async ({ 8 | filePath, 9 | }: { 10 | filePath: string; 11 | }) => { 12 | // grab file contents 13 | const content = await readFileAsync({ filePath }); 14 | 15 | // grab the sql definition w/ regex 16 | const [ 17 | _, // tslint:disable-line no-unused 18 | sqlMatch, 19 | ] = new RegExp(/(?:export const sql = `)((.|\n)+)(?:`)/).exec(content) ?? []; 20 | 21 | // throw error if could not extract 22 | if (!sqlMatch) 23 | throw new Error(`could not extract sql from file at path '${filePath}'`); 24 | 25 | // return the sql 26 | return sqlMatch; 27 | }; 28 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/schema/functions/hash_string.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION `get_from_delimiter_split_string`( 2 | in_array varchar(255), 3 | in_delimiter char(1), 4 | in_index int 5 | ) RETURNS varchar(255) CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci 6 | BEGIN 7 | /* 8 | usage: 9 | SELECT get_from_delimiter_split_string('1,5,3,7,4', ',', 1); -- returns '5' 10 | SELECT get_from_delimiter_split_string('1,5,3,7,4', ',', 10); -- returns '' 11 | */ 12 | return REPLACE( -- remove the delimiters after doing the following: 13 | SUBSTRING( -- pick the string 14 | SUBSTRING_INDEX(in_array, in_delimiter, in_index + 1), -- from the string up to index+1 counts of the delimiter 15 | LENGTH( 16 | SUBSTRING_INDEX(in_array, in_delimiter, in_index) -- keeping only everything after index counts of the delimiter 17 | ) + 1 18 | ), 19 | in_delimiter, 20 | '' 21 | ); 22 | END 23 | -------------------------------------------------------------------------------- /src/logic/commands/generate/defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions/utils/getRelativePathFromFileToFile.test.ts: -------------------------------------------------------------------------------- 1 | import { getRelativePathFromFileToFile } from './getRelativePathFromFileToFile'; 2 | 3 | describe('getRelativeFilePathFromFileToFile', () => { 4 | it('should be able to get correct path between files in same dir', () => { 5 | const relativePath = getRelativePathFromFileToFile({ 6 | fromFile: 'src/generated/fromSql/queryFunction.ts', 7 | toFile: 'src/generated/fromSql/types.ts', 8 | }); 9 | expect(relativePath).toEqual('./types'); 10 | }); 11 | it('should be able to get correct path between files in different dirs', () => { 12 | const relativePath = getRelativePathFromFileToFile({ 13 | fromFile: 'src/generated/fromSql/queryFunction.ts', 14 | toFile: 'src/dao/user/findAllByName.ts', 15 | }); 16 | expect(relativePath).toEqual('../../dao/user/findAllByName'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/contract/commands/generate.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from '@oclif/core'; 2 | 3 | import { generate } from '../../logic/commands/generate/generate'; 4 | 5 | export default class Generate extends Command { 6 | public static description = 7 | 'generate typescript code by parsing sql definitions for types and usage'; 8 | 9 | public static flags = { 10 | help: Flags.help({ char: 'h' }), 11 | config: Flags.string({ 12 | char: 'c', 13 | description: 'path to config yml', 14 | required: true, 15 | default: 'codegen.sql.yml', 16 | }), 17 | }; 18 | 19 | public async run() { 20 | const { flags } = await this.parse(Generate); 21 | const config = flags.config!; 22 | 23 | // generate the code 24 | const configPath = 25 | config.slice(0, 1) === '/' ? config : `${process.cwd()}/${config}`; // if starts with /, consider it as an absolute path 26 | await generate({ configPath }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/logic/common/extractSqlFromFile/extractSqlFromFile.ts: -------------------------------------------------------------------------------- 1 | import { extractSqlFromSqlFile } from './extractSqlFromSqlFile'; 2 | import { extractSqlFromTsFile } from './extractSqlFromTsFile'; 3 | 4 | const SUPPORTED_FILE_EXTENSIONS = ['.sql', '.ts']; 5 | export const extractSqlFromFile = async ({ 6 | filePath, 7 | }: { 8 | filePath: string; 9 | }) => { 10 | // 1. grab extension from file; check that we support it 11 | const extension = `.${filePath.split('.').slice(-1)[0]}`; 12 | if (!SUPPORTED_FILE_EXTENSIONS.includes(extension)) { 13 | throw new Error(`file extension '.${extension}' is not supported`); 14 | } 15 | 16 | // 2. grab sql from file, based on file type 17 | if (extension === '.sql') return extractSqlFromSqlFile({ filePath }); 18 | if (extension === '.ts') return extractSqlFromTsFile({ filePath }); 19 | throw new Error('unexpected'); // should never reach here, since we checked earlier that the extension was supported 20 | }; 21 | -------------------------------------------------------------------------------- /src/contract/__test_assets__/schema/functions/upsert_home.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION `upsert_home`( 2 | in_user_id varchar(190), 3 | in_address varchar(190) 4 | ) RETURNS bigint(20) 5 | BEGIN 6 | -- declarations 7 | DECLARE v_static_id BIGINT; 8 | DECLARE v_created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6); -- define a common created_at timestamp to use 9 | 10 | -- find or create the static entity 11 | SET v_static_id = ( 12 | SELECT id 13 | FROM image 14 | WHERE 1=1 15 | AND (user_id = BINARY in_user_id) 16 | AND (address = BINARY in_address) 17 | ); 18 | IF (v_static_id IS NULL) THEN -- if entity could not be found originally, create the static entity 19 | INSERT INTO image 20 | (uuid, created_at, user_id, address) 21 | VALUES 22 | (uuid(), v_created_at, in_user_id, in_address) 23 | SET v_static_id = ( 24 | SELECT last_insert_id() 25 | ); 26 | END IF; 27 | 28 | -- return the static entity id 29 | return v_static_id; 30 | END 31 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/flattenSqlByReferencingAndTokenizingSubqueries/flattenSqlByReferencingAndTokenizingSubqueries.ts: -------------------------------------------------------------------------------- 1 | import { breakSqlIntoNestedSqlArraysAtParentheses } from './breakSqlIntoNestedSqlArraysAtParentheses'; 2 | import { flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive } from './flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive'; 3 | 4 | export const flattenSqlByReferencingAndTokenizingSubqueries = ({ 5 | sql, 6 | }: { 7 | sql: string; 8 | }) => { 9 | // 1. get the nested sql array 10 | const nestedSqlArray = breakSqlIntoNestedSqlArraysAtParentheses({ sql }); 11 | 12 | // 2. get the references and flattened sql 13 | const { references, flattenedSql } = 14 | flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive({ 15 | sqlOrNestedSqlArray: nestedSqlArray, 16 | }); 17 | 18 | // 3. return the references and flattened sql 19 | return { 20 | references, 21 | flattenedSql, 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/logic/common/extractSqlFromFile/__test_assets__/importAndExportThingsIncludingSql.ts: -------------------------------------------------------------------------------- 1 | // import { dbConnection } from '../../../utils/database'; 2 | // import { sqlQueryFindSuggestionById } from '../generated/queries'; 3 | // import { fromDatabaseObject } from './fromDatabaseObject'; 4 | 5 | export const sql = ` 6 | -- query_name = find_suggestion_by_id 7 | select 8 | s.id, 9 | s.uuid, 10 | s.suggestion_source, 11 | s.external_id, 12 | s.suggested_idea_id, 13 | s.status, 14 | s.result, 15 | s.resultant_curated_idea_id 16 | from view_suggestion_current s 17 | where s.id = :id 18 | `.trim(); 19 | 20 | export const findById = ({ id }: { id: number }) => { 21 | // const suggestions = sqlQueryFindSuggestionById({ dbExecute: dbConnection.execute, input: { id } }); 22 | // if (suggestions.length > 0) throw new Error('should only be one'); 23 | // return fromDatabaseObject({ databaseObject: suggestions[0] }); 24 | console.log(id); // tslint:disable-line no-console 25 | }; 26 | -------------------------------------------------------------------------------- /src/logic/commands/generate/saveCode.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import { mkdir, writeFile } from '../utils/fileIO'; 4 | 5 | export const saveCode = async ({ 6 | rootDir, 7 | filePath, 8 | code, 9 | }: { 10 | rootDir: string; 11 | filePath: string; 12 | code: string; 13 | }) => { 14 | // absolute file path 15 | const absoluteFilePath = `${rootDir}/${filePath}`; 16 | const targetDirPath = absoluteFilePath.split('/').slice(0, -1).join('/'); 17 | 18 | // ensure directory is defined 19 | await mkdir(targetDirPath).catch((error) => { 20 | if (error.code !== 'EEXIST') throw error; 21 | }); // mkdir and ignore if dir already exists 22 | 23 | // write the resource sql to that dir 24 | await writeFile(absoluteFilePath, code); 25 | 26 | // log that we have successfully written 27 | const successMessage = ` ${chalk.green('✔')} ${chalk.green( 28 | chalk.bold('[GENERATED]'), 29 | )} ${chalk.bold(filePath)}`; 30 | console.log(successMessage); // tslint:disable-line no-console 31 | }; 32 | -------------------------------------------------------------------------------- /src/contract/__test_assets__/schema/functions/upsert_user.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION `upsert_user`( 2 | in_first_name varchar(190), 3 | in_last_name varchar(190) 4 | ) RETURNS bigint(20) 5 | BEGIN 6 | -- declarations 7 | DECLARE v_static_id BIGINT; 8 | DECLARE v_created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6); -- define a common created_at timestamp to use 9 | 10 | -- find or create the static entity 11 | SET v_static_id = ( 12 | SELECT id 13 | FROM image 14 | WHERE 1=1 15 | AND (first_name = BINARY in_first_name) 16 | AND (last_name = BINARY in_last_name) 17 | ); 18 | IF (v_static_id IS NULL) THEN -- if entity could not be found originally, create the static entity 19 | INSERT INTO image 20 | (uuid, created_at, first_name, last_name) 21 | VALUES 22 | (uuid(), v_created_at, in_first_name, in_last_name) 23 | SET v_static_id = ( 24 | SELECT last_insert_id() 25 | ); 26 | END IF; 27 | 28 | -- return the static entity id 29 | return v_static_id; 30 | END 31 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractNameFromQuerySql.test.ts: -------------------------------------------------------------------------------- 1 | import { extractSqlFromFile } from '../../common/extractSqlFromFile'; 2 | import { extractNameFromQuerySql } from './extractNameFromQuerySql'; 3 | 4 | describe('extractNameFromQuerySql', () => { 5 | it('should be able to determine types accurately for this example', async () => { 6 | const sql = await extractSqlFromFile({ 7 | filePath: `${__dirname}/__test_assets__/find_all_by_name_excluding_one_field.sql`, 8 | }); 9 | const name = extractNameFromQuerySql({ sql }); 10 | expect(name).toEqual('find_all_by_name'); 11 | }); 12 | it('should throw an error if query name is not defined', async () => { 13 | const sql = await extractSqlFromFile({ 14 | filePath: `${__dirname}/__test_assets__/query_without_name.sql`, 15 | }); 16 | try { 17 | extractNameFromQuerySql({ sql }); 18 | } catch (error) { 19 | expect(error.message).toContain( 20 | 'sql for query does not have name defined', 21 | ); 22 | } 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/domain/objects/TypeDefinitionOfResourceView.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | import { TypeDefinitionOfQuerySelectExpression } from './TypeDefinitionOfQuerySelectExpression'; 5 | import { TypeDefinitionOfQueryTableReference } from './TypeDefinitionOfQueryTableReference'; 6 | 7 | const schema = Joi.object().keys({ 8 | name: Joi.string().required(), 9 | selectExpressions: Joi.array() 10 | .items(TypeDefinitionOfQuerySelectExpression.schema) 11 | .required(), 12 | tableReferences: Joi.array() 13 | .items(TypeDefinitionOfQueryTableReference.schema) 14 | .required(), 15 | }); 16 | export interface TypeDefinitionOfResourceView { 17 | name: string; 18 | selectExpressions: TypeDefinitionOfQuerySelectExpression[]; 19 | tableReferences: TypeDefinitionOfQueryTableReference[]; 20 | } 21 | export class TypeDefinitionOfResourceView 22 | extends DomainObject 23 | implements TypeDefinitionOfResourceView 24 | { 25 | public static schema = schema; 26 | } 27 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/flattenSqlByReferencingAndTokenizingSubqueries/__snapshots__/flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive should accurately flatten and tokenize this example 1`] = ` 4 | [ 5 | SqlSubqueryReference { 6 | "id": "__UUID__", 7 | "sql": "( 8 | SELECT GROUP_CONCAT(ice_cream_to_ingredient.ingredient_id ORDER BY ice_cream_to_ingredient.ingredient_id ASC SEPARATOR ',') 9 | FROM ice_cream_to_ingredient WHERE ice_cream_to_ingredient.ice_cream_id = s.id 10 | )", 11 | }, 12 | ] 13 | `; 14 | 15 | exports[`flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive should accurately flatten and tokenize this example 2`] = ` 16 | "-- query_name = find_with_subselect_in_select_expressions 17 | SELECT 18 | s.id, 19 | s.uuid, 20 | s.name, 21 | __SSQ:__UUID____ as ingredient_ids, 22 | s.created_at 23 | FROM ice_cream s 24 | ; 25 | " 26 | `; 27 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/common/castCommasInParensToPipesForTokenSafety.test.ts: -------------------------------------------------------------------------------- 1 | import { extractSqlFromFile } from '../../../common/extractSqlFromFile'; 2 | import { castCommasInParensToPipesForTokenSafety } from './castCommasInParensToPipesForTokenSafety'; 3 | 4 | describe('castCommasInParensToPipesForTokenSafety', () => { 5 | it('should cast correctly for this example', async () => { 6 | const exampleSql = await extractSqlFromFile({ 7 | filePath: `${__dirname}/__test_assets__/suggestion_version.insides.sql`, 8 | }); 9 | const castedSql = castCommasInParensToPipesForTokenSafety({ 10 | sql: exampleSql, 11 | }); 12 | expect(castedSql).toMatchSnapshot(); 13 | }); 14 | it('should cast correctly for this example with nested parens', async () => { 15 | const exampleSql = await extractSqlFromFile({ 16 | filePath: `${__dirname}/__test_assets__/job.insides.sql`, 17 | }); 18 | const castedSql = castCommasInParensToPipesForTokenSafety({ 19 | sql: exampleSql, 20 | }); 21 | expect(castedSql).toMatchSnapshot(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/table/extractTypeDefinitionFromColumnSql.ts: -------------------------------------------------------------------------------- 1 | import { DataType, TypeDefinitionOfResourceColumn } from '../../../../domain'; 2 | import { extractDataTypeFromColumnOrArgumentDefinitionSql } from '../common/extractDataTypeFromColumnOrArgumentDefinitionSql'; 3 | 4 | export const extractTypeDefinitionFromColumnSql = ({ 5 | sql, 6 | }: { 7 | sql: string; 8 | }) => { 9 | // 1. extract the name; its typically the first string 10 | const name = sql.split(' ')[0]!.replace(/[^a-zA-Z_]+/gi, ''); 11 | 12 | // 2. extract the root type; 13 | const primaryType = extractDataTypeFromColumnOrArgumentDefinitionSql({ sql }); 14 | 15 | // 3. determine if its nullable 16 | const isNullable = !sql.toUpperCase().includes(' NOT NULL'); 17 | 18 | // 4. define the full type definition; note: array => union 19 | const type = [primaryType, isNullable ? DataType.NULL : null].filter( 20 | (thisType) => !!thisType, 21 | ) as DataType[]; 22 | 23 | // 5. return the definition 24 | return new TypeDefinitionOfResourceColumn({ 25 | name, 26 | type, 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/functions/upsert_photo.postgres.sql: -------------------------------------------------------------------------------- 1 | /* 2 | generated by https://github.com/uladkasach/sql-schema-generator @v0.18.3 3 | */ 4 | CREATE OR REPLACE FUNCTION upsert_photo( 5 | in_url varchar, 6 | in_description varchar 7 | ) 8 | RETURNS bigint 9 | LANGUAGE plpgsql 10 | AS $$ 11 | DECLARE 12 | v_static_id bigint; 13 | v_created_at timestamptz := now(); -- define a common created_at timestamp to use 14 | BEGIN 15 | -- find or create the static entity 16 | SELECT id INTO v_static_id -- try to find id of entity 17 | FROM photo 18 | WHERE 1=1 19 | AND (url = in_url) 20 | AND (description = in_description OR (description IS null AND in_description IS null)); 21 | IF (v_static_id IS NULL) THEN -- if entity could not be already found, create the static entity 22 | INSERT INTO photo 23 | (uuid, created_at, url, description) 24 | VALUES 25 | (uuid_generate_v4(), v_created_at, in_url, in_description) 26 | RETURNING id INTO v_static_id; 27 | END IF; 28 | 29 | -- return the static entity id 30 | RETURN v_static_id; 31 | END; 32 | $$ 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Uladzimir Kasacheuski 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 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/function/extractInputsFromFunctionSql/extractTypeDefinitionFromFunctionInputSql.ts: -------------------------------------------------------------------------------- 1 | import { DataType } from '../../../../../domain'; 2 | import { TypeDefinitionOfResourceInput } from '../../../../../domain/objects/TypeDefinitionOfResourceInput'; 3 | import { extractDataTypeFromColumnOrArgumentDefinitionSql } from '../../common/extractDataTypeFromColumnOrArgumentDefinitionSql'; 4 | 5 | export const extractTypeDefinitionFromFunctionInputSql = ({ 6 | sql, 7 | }: { 8 | sql: string; 9 | }) => { 10 | // 1. extract the name; its typically the first string 11 | const name = sql.split(' ')[0]!.replace(/[^a-zA-Z_]+/gi, ''); 12 | 13 | // 2. extract the root type; 14 | const primaryType = extractDataTypeFromColumnOrArgumentDefinitionSql({ sql }); 15 | 16 | // 3. define the full type definition; note: array => union 17 | const type = [ 18 | primaryType, 19 | DataType.NULL, // note: inputs to functions are always nullable ? 20 | ].filter((type) => !!type) as DataType[]; 21 | 22 | // 4. return the definition 23 | return new TypeDefinitionOfResourceInput({ 24 | name, 25 | type, 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/postgres/schema/functions/upsert_image.sql: -------------------------------------------------------------------------------- 1 | /* 2 | generated by https://github.com/uladkasach/sql-schema-generator @v0.18.3 3 | */ 4 | CREATE OR REPLACE FUNCTION upsert_photo( 5 | in_url varchar, 6 | in_description varchar 7 | ) 8 | RETURNS bigint 9 | LANGUAGE plpgsql 10 | AS $$ 11 | DECLARE 12 | v_static_id bigint; 13 | v_created_at timestamptz := now(); -- define a common created_at timestamp to use 14 | BEGIN 15 | -- find or create the static entity 16 | SELECT id INTO v_static_id -- try to find id of entity 17 | FROM photo 18 | WHERE 1=1 19 | AND (url = in_url) 20 | AND (description = in_description OR (description IS null AND in_description IS null)); 21 | IF (v_static_id IS NULL) THEN -- if entity could not be already found, create the static entity 22 | INSERT INTO photo 23 | (uuid, created_at, url, description) 24 | VALUES 25 | (uuid_generate_v4(), v_created_at, in_url, in_description) 26 | RETURNING id INTO v_static_id; 27 | END IF; 28 | 29 | -- return the static entity id 30 | RETURN v_static_id; 31 | END; 32 | $$ 33 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/resource/table/__snapshots__/defineTypescriptTypesForTable.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`defineTypescriptTypesForTable should generate an accurate looking interface for this other table definition 1`] = ` 4 | "export interface SqlTableSuggestionVersion { 5 | id: number; 6 | suggestion_id: number; 7 | effective_at: Date; 8 | created_at: Date; 9 | status: string; 10 | }" 11 | `; 12 | 13 | exports[`defineTypescriptTypesForTable should generate an accurate looking interface for this other table definition, again 1`] = ` 14 | "export interface SqlTableJobVersion { 15 | id: number; 16 | job_id: number; 17 | effective_at: Date; 18 | created_at: Date; 19 | location_id: number; 20 | photo_ids_hash: Buffer; 21 | }" 22 | `; 23 | 24 | exports[`defineTypescriptTypesForTable should generate an accurate looking interface for this table definition 1`] = ` 25 | "export interface SqlTableImage { 26 | id: number; 27 | uuid: string; 28 | created_at: Date; 29 | url: string; 30 | caption: string | null; 31 | credit: string | null; 32 | alt_text: string | null; 33 | }" 34 | `; 35 | -------------------------------------------------------------------------------- /src/domain/objects/TypeDefinitionOfQueryInputVariable.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | import { DataType } from '../constants'; 5 | import { TypeDefinitionReference } from './TypeDefinitionReference'; 6 | 7 | const schema = Joi.object().keys({ 8 | name: Joi.string().required(), 9 | type: Joi.alternatives([ 10 | TypeDefinitionReference.schema.required(), 11 | Joi.array().items(Joi.string().valid(...Object.values(DataType))), 12 | ]), 13 | plural: Joi.boolean().required(), 14 | }); 15 | export interface TypeDefinitionOfQueryInputVariable { 16 | /** 17 | * e.g., ":externalId" => "externalId" 18 | */ 19 | name: string; 20 | 21 | /** 22 | * e.g., either an explicit type or a reference to the type on a sql resource 23 | */ 24 | type: TypeDefinitionReference | DataType[]; 25 | 26 | /** 27 | * whether its a plural of this type or not 28 | */ 29 | plural: boolean; 30 | } 31 | export class TypeDefinitionOfQueryInputVariable 32 | extends DomainObject 33 | implements TypeDefinitionOfQueryInputVariable 34 | { 35 | public static schema = schema; 36 | } 37 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/common/defineTypescriptTypeFromReference/defineTypescriptTypeFromFunctionReference.test.ts: -------------------------------------------------------------------------------- 1 | import { TypeDefinitionReference } from '../../../../domain/objects/TypeDefinitionReference'; 2 | import { defineTypescriptTypeFromFunctionReference } from './defineTypescriptTypeFromFunctionReference'; 3 | 4 | describe('defineTypescriptTypeFromFunctionReference', () => { 5 | it('should be able to define type referencing a function input', () => { 6 | const code = defineTypescriptTypeFromFunctionReference({ 7 | reference: new TypeDefinitionReference({ 8 | tableReferencePath: null, 9 | functionReferencePath: 'upsert_user.input.2', 10 | }), 11 | }); 12 | expect(code).toEqual("SqlFunctionUpsertUserInput['2']"); 13 | }); 14 | it('should be able to define type referencing a function output', () => { 15 | const code = defineTypescriptTypeFromFunctionReference({ 16 | reference: new TypeDefinitionReference({ 17 | tableReferencePath: null, 18 | functionReferencePath: 'upsert_user.output', 19 | }), 20 | }); 21 | expect(code).toEqual('SqlFunctionUpsertUserOutput'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/flattenSqlByReferencingAndTokenizingSubqueries/breakSqlIntoNestedSqlArraysAtParentheses.ts: -------------------------------------------------------------------------------- 1 | type ValueOrArray = T | ValueOrArray[]; 2 | export type NestedStringArray = ValueOrArray[]; 3 | 4 | const castNewlinesAndQuotesIntoJSONParsableNewlinesAndQuotes = (str: string) => 5 | str.replace(/\n/g, '\\n').replace(/"/g, '\\"'); 6 | export const breakSqlIntoNestedSqlArraysAtParentheses = ({ 7 | sql, 8 | }: { 9 | sql: string; 10 | }): NestedStringArray => { 11 | const sqlWithNewlinesReplaced = 12 | castNewlinesAndQuotesIntoJSONParsableNewlinesAndQuotes(sql); 13 | const sqlWithLeftParensReplaced = sqlWithNewlinesReplaced.replace( 14 | /\(/g, 15 | '",["(', 16 | ); 17 | const sqlWithLeftAndRightParensReplaced = sqlWithLeftParensReplaced.replace( 18 | /\)/g, 19 | ')"],"', 20 | ); 21 | const jsonArrayOfParenNestedSql = `["${sqlWithLeftAndRightParensReplaced}"]`; 22 | const arrayOfParenNestedSql: NestedStringArray = JSON.parse( 23 | jsonArrayOfParenNestedSql, 24 | ); // use json to break it up for us; inspiration: https://stackoverflow.com/a/40478000/3068233 25 | return arrayOfParenNestedSql; 26 | }; 27 | -------------------------------------------------------------------------------- /src/domain/objects/TypeDefinitionOfResourceFunction.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | import { DataType } from '../constants'; 5 | import { TypeDefinitionOfResourceInput } from './TypeDefinitionOfResourceInput'; 6 | import { TypeDefinitionOfResourceTable } from './TypeDefinitionOfResourceTable'; 7 | 8 | const schema = Joi.object().keys({ 9 | name: Joi.string().required(), 10 | inputs: Joi.array().items(TypeDefinitionOfResourceInput.schema).required(), 11 | output: Joi.alternatives( 12 | Joi.array() 13 | .items(Joi.string().valid(...Object.values(DataType))) 14 | .required(), 15 | TypeDefinitionOfResourceTable.schema, 16 | ), 17 | }); 18 | export interface TypeDefinitionOfResourceFunction { 19 | name: string; 20 | inputs: TypeDefinitionOfResourceInput[]; 21 | output: DataType[] | TypeDefinitionOfResourceTable; // functions can return a value or a table (e.g., postgres functions can return tables) 22 | } 23 | export class TypeDefinitionOfResourceFunction 24 | extends DomainObject 25 | implements TypeDefinitionOfResourceFunction 26 | { 27 | public static schema = schema; 28 | } 29 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/flattenSqlByReferencingAndTokenizingSubqueries/breakSqlIntoNestedSqlArraysAtParentheses.test.ts: -------------------------------------------------------------------------------- 1 | import { extractSqlFromFile } from '../../../../common/extractSqlFromFile'; 2 | import { breakSqlIntoNestedSqlArraysAtParentheses } from './breakSqlIntoNestedSqlArraysAtParentheses'; 3 | 4 | describe('breakSqlIntoNestedSqlArraysAtParentheses', () => { 5 | it('should accurately break up this example', async () => { 6 | const sql = await extractSqlFromFile({ 7 | filePath: `${__dirname}/../../__test_assets__/find_with_subselect_in_select_expressions.sql`, 8 | }); 9 | const nestedSqlArray = breakSqlIntoNestedSqlArraysAtParentheses({ sql }); 10 | expect(nestedSqlArray).toHaveLength(3); 11 | expect(nestedSqlArray).toMatchSnapshot(); 12 | }); 13 | it('should retain the parentheses inside of the nested sql arrays', () => { 14 | const sql = "SELECT CONCAT('hel', 'lo');"; 15 | const nestedSqlArray = breakSqlIntoNestedSqlArraysAtParentheses({ sql }); 16 | expect(nestedSqlArray).toHaveLength(3); 17 | expect(nestedSqlArray[1]![0]![0]).toEqual('('); 18 | expect(nestedSqlArray[1]![0]!.slice(-1)[0]).toEqual(')'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/logic/common/extractSqlFromFile/extractSqlFromTsFile.test.ts: -------------------------------------------------------------------------------- 1 | import { extractSqlFromTsFile } from './extractSqlFromTsFile'; 2 | 3 | const TEST_ASSETS_DIR = `${__dirname}/__test_assets__`; 4 | 5 | describe('extractSqlFromTsFile', () => { 6 | it('should extract sql from a file that only has sql exported', async () => { 7 | const sql = await extractSqlFromTsFile({ 8 | filePath: `${TEST_ASSETS_DIR}/onlyExportSql.ts`, 9 | }); 10 | expect(sql).toMatchSnapshot(); 11 | }); 12 | it('should extract sql that exports the sql and imports and exports other things', async () => { 13 | const sql = await extractSqlFromTsFile({ 14 | filePath: `${TEST_ASSETS_DIR}/importAndExportThingsIncludingSql.ts`, 15 | }); 16 | expect(sql).toMatchSnapshot(); 17 | }); 18 | it('should throw standard error if sql could not be extracted', async () => { 19 | try { 20 | await extractSqlFromTsFile({ 21 | filePath: `${TEST_ASSETS_DIR}/dontExportSql.ts`, 22 | }); 23 | throw new Error('should not reach here'); 24 | } catch (error) { 25 | expect(error.message).toContain( 26 | 'could not extract sql from file at path ', 27 | ); 28 | } 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/resource/table/defineTypescriptTypesForTable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TypeDefinitionOfResourceTable, 3 | ResourceType, 4 | } from '../../../../domain'; 5 | import { castResourceNameToTypescriptTypeName } from '../../common/castResourceNameToTypescriptTypeName'; 6 | import { defineTypescriptTypeFromDataTypeArray } from '../../common/defineTypescriptTypeFromDataTypeArray'; 7 | 8 | export const defineTypescriptTypesForTable = ({ 9 | definition, 10 | }: { 11 | definition: TypeDefinitionOfResourceTable; 12 | }) => { 13 | // define column types in typescript format 14 | const typescriptInterfaceColumnDefinitions = definition.columns.map( 15 | (column) => { 16 | return `${column.name}: ${defineTypescriptTypeFromDataTypeArray({ 17 | type: column.type, 18 | })};`; 19 | }, 20 | ); 21 | 22 | // output 23 | const typescriptInterfaceDefinition = ` 24 | export interface ${castResourceNameToTypescriptTypeName({ 25 | name: definition.name, 26 | resourceType: ResourceType.TABLE, 27 | })} { 28 | ${typescriptInterfaceColumnDefinitions.join('\n ')} 29 | } 30 | `.trim(); 31 | 32 | // return typescript types 33 | return typescriptInterfaceDefinition; 34 | }; 35 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractTableReferencesFromQuerySql/extractTableReferenceSqlSetFromQuerySql.test.ts: -------------------------------------------------------------------------------- 1 | import { extractSqlFromFile } from '../../../common/extractSqlFromFile'; 2 | import { extractTableReferenceSqlSetFromQuerySql } from './extractTableReferenceSqlSetFromQuerySql'; 3 | 4 | describe('extractTableReferenceSqlSetFromQuerySql', () => { 5 | it('should be able to determine types accurately for this example', async () => { 6 | const sql = await extractSqlFromFile({ 7 | filePath: `${__dirname}/../../../__test_assets__/queries/find_image_by_id.sql`, 8 | }); 9 | const defs = extractTableReferenceSqlSetFromQuerySql({ sql }); 10 | expect(defs).toEqual(['FROM image i']); 11 | }); 12 | it('should be able to determine types accurately for this other example', async () => { 13 | const sql = await extractSqlFromFile({ 14 | filePath: `${__dirname}/../../../__test_assets__/queries/select_suggestion.sql`, 15 | }); 16 | const defs = extractTableReferenceSqlSetFromQuerySql({ sql }); 17 | expect(defs).toEqual([ 18 | 'FROM suggestion s', 19 | 'JOIN suggestion_cvp cvp ON s.id = cvp.suggestion_id', 20 | 'JOIN suggestion_version v ON v.id = cvp.suggestion_version_id', 21 | ]); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/upsert_train_with_unnesting_uuids.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | dgv.id, dgv.uuid 3 | FROM upsert_train( 4 | :homeStationGeocodeId, 5 | :combinationId, 6 | ( 7 | SELECT COALESCE(array_agg(locomotive.id ORDER BY locomotive_ref.array_order_index), array[]::bigint[]) AS array_agg 8 | FROM locomotive 9 | JOIN unnest(:locomotiveUuids::uuid[]) WITH ORDINALITY 10 | AS locomotive_ref (uuid, array_order_index) 11 | ON locomotive.uuid = locomotive_ref.uuid 12 | ), 13 | ( 14 | SELECT COALESCE(array_agg(carriage.id ORDER BY carriage_ref.array_order_index), array[]::bigint[]) AS array_agg 15 | FROM carriage 16 | JOIN unnest(:carriageUuids::uuid[]) WITH ORDINALITY 17 | AS carriage_ref (uuid, array_order_index) 18 | ON carriage.uuid = carriage_ref.uuid 19 | ), 20 | ( 21 | SELECT COALESCE(array_agg(train_engineer.id ORDER BY train_engineer_ref.array_order_index), array[]::bigint[]) AS array_agg 22 | FROM train_engineer 23 | JOIN unnest(:engineerUuids::uuid[]) WITH ORDINALITY 24 | AS train_engineer_ref (uuid, array_order_index) 25 | ON train_engineer.uuid = train_engineer_ref.uuid 26 | ), 27 | (SELECT id FROM train_engineer WHERE train_engineer.uuid = :leadEngineerUuid), 28 | :status 29 | ) as dgv; 30 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/common/defineTypescriptTypeFromDataTypeArrayOrReference.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataType, 3 | TypeDefinitionReference, 4 | TypeDefinition, 5 | TypeDefinitionOfQueryTableReference, 6 | } from '../../../domain'; 7 | import { defineTypescriptTypeFromDataTypeArray } from './defineTypescriptTypeFromDataTypeArray'; 8 | import { defineTypescriptTypeFromReference } from './defineTypescriptTypeFromReference/defineTypescriptTypeFromReference'; 9 | 10 | export const defineTypescriptTypeFromDataTypeArrayOrReference = ({ 11 | type, 12 | plural, 13 | queryTableReferences, 14 | typeDefinitions, 15 | }: { 16 | type: DataType[] | TypeDefinitionReference; 17 | plural: boolean; 18 | typeDefinitions: TypeDefinition[]; 19 | queryTableReferences: TypeDefinitionOfQueryTableReference[]; 20 | }) => { 21 | // if its a type reference, then use that handler 22 | if (type instanceof TypeDefinitionReference) { 23 | const defined = defineTypescriptTypeFromReference({ 24 | reference: type, 25 | queryTableReferences, 26 | typeDefinitions, 27 | }); 28 | if (plural) return `${defined}[]`; 29 | return defined; 30 | } 31 | 32 | // else, it must be data array. use that handler 33 | return defineTypescriptTypeFromDataTypeArray({ type }); 34 | }; 35 | -------------------------------------------------------------------------------- /src/domain/objects/GeneratorConfig.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | import { DatabaseLanguage, GeneratedOutputPaths } from '../constants'; 5 | import { QueryDeclaration } from './QueryDeclaration'; 6 | import { ResourceDeclaration } from './ResourceDeclaration'; 7 | 8 | const generatorConfigSchema = Joi.object().keys({ 9 | rootDir: Joi.string().required(), // dir of config file, to which all config paths are relative 10 | language: Joi.string().valid(...Object.values(DatabaseLanguage)), 11 | dialect: Joi.string().required(), 12 | generates: Joi.object().keys({ 13 | types: Joi.string().required(), 14 | queryFunctions: Joi.string().optional(), 15 | }), 16 | declarations: Joi.array().items( 17 | QueryDeclaration.schema, 18 | ResourceDeclaration.schema, 19 | ), 20 | }); 21 | 22 | type DeclarationObject = QueryDeclaration | ResourceDeclaration; 23 | export interface GeneratorConfig { 24 | rootDir: string; 25 | generates: GeneratedOutputPaths; 26 | language: DatabaseLanguage; 27 | dialect: string; 28 | declarations: DeclarationObject[]; 29 | } 30 | export class GeneratorConfig 31 | extends DomainObject 32 | implements GeneratorConfig 33 | { 34 | public static schema = generatorConfigSchema; 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/.publish-npm.yml: -------------------------------------------------------------------------------- 1 | name: .publish-npm 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | npm-auth-token: 7 | required: true 8 | description: required credentials to authenticate with the aws account under which to publish 9 | 10 | jobs: 11 | install: 12 | uses: ./.github/workflows/.install.yml 13 | 14 | publish: 15 | runs-on: ubuntu-20.04 16 | needs: [install] 17 | steps: 18 | - name: checkout 19 | uses: actions/checkout@v3 20 | 21 | - name: set node-version 22 | uses: actions/setup-node@v3 23 | with: 24 | registry-url: 'https://registry.npmjs.org/' 25 | node-version-file: '.nvmrc' 26 | 27 | - name: node-modules cache get 28 | uses: actions/cache/restore@v3 29 | id: cache 30 | with: 31 | path: ./node_modules 32 | key: ${{ needs.install.outputs.node-modules-cache-key }} 33 | 34 | - name: node-modules cache miss install 35 | if: steps.cache.outputs.cache-hit != 'true' 36 | run: npm ci --ignore-scripts --prefer-offline --no-audit 37 | 38 | - name: build 39 | run: npm run build 40 | 41 | - name: publish 42 | run: npm publish --access public 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.npm-auth-token }} 45 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/common/__snapshots__/castCommasInParensToPipesForTokenSafety.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`castCommasInParensToPipesForTokenSafety should cast correctly for this example 1`] = ` 4 | " \`id\` bigint(20) NOT NULL AUTO_INCREMENT, 5 | \`suggestion_id\` bigint(20) NOT NULL, 6 | \`effective_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 7 | \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), 8 | \`status\` enum('PENDING'|'REVIEWED') COLLATE utf8mb4_bin NOT NULL, 9 | PRIMARY KEY (\`id\`), 10 | UNIQUE KEY \`suggestion_version_ux1\` (\`suggestion_id\`|\`effective_at\`|\`created_at\`), 11 | KEY \`suggestion_version_fk0\` (\`suggestion_id\`), 12 | CONSTRAINT \`suggestion_version_fk0\` FOREIGN KEY (\`suggestion_id\`) REFERENCES \`suggestion\` (\`id\`) 13 | " 14 | `; 15 | 16 | exports[`castCommasInParensToPipesForTokenSafety should cast correctly for this example with nested parens 1`] = ` 17 | " id bigserial NOT NULL, 18 | uuid uuid NOT NULL, 19 | created_at timestamp with time zone NOT NULL DEFAULT now(), 20 | status varchar NOT NULL, 21 | CONSTRAINT job_pk PRIMARY KEY (id), 22 | CONSTRAINT job_ux1 UNIQUE (uuid), 23 | CONSTRAINT job_status_check CHECK (status IN ('QUEUED'| 'ATTEMPTED'| 'FULFILLED'| 'FAILED'| 'CANCELED')) 24 | " 25 | `; 26 | -------------------------------------------------------------------------------- /src/domain/objects/TypeDefinitionOfQuery.ts: -------------------------------------------------------------------------------- 1 | import { DomainObject } from 'domain-objects'; 2 | import Joi from 'joi'; 3 | 4 | import { TypeDefinitionOfQueryInputVariable } from './TypeDefinitionOfQueryInputVariable'; 5 | import { TypeDefinitionOfQuerySelectExpression } from './TypeDefinitionOfQuerySelectExpression'; 6 | import { TypeDefinitionOfQueryTableReference } from './TypeDefinitionOfQueryTableReference'; 7 | 8 | const schema = Joi.object().keys({ 9 | name: Joi.string().required(), 10 | path: Joi.string().required(), // path to the sql that the typedef was based on 11 | selectExpressions: Joi.array() 12 | .items(TypeDefinitionOfQuerySelectExpression.schema) 13 | .required(), 14 | tableReferences: Joi.array() 15 | .items(TypeDefinitionOfQueryTableReference.schema) 16 | .required(), 17 | inputVariables: Joi.array() 18 | .items(TypeDefinitionOfQueryInputVariable.schema) 19 | .required(), 20 | }); 21 | export interface TypeDefinitionOfQuery { 22 | name: string; 23 | path: string; 24 | selectExpressions: TypeDefinitionOfQuerySelectExpression[]; 25 | tableReferences: TypeDefinitionOfQueryTableReference[]; 26 | inputVariables: TypeDefinitionOfQueryInputVariable[]; 27 | } 28 | export class TypeDefinitionOfQuery 29 | extends DomainObject 30 | implements TypeDefinitionOfQuery 31 | { 32 | public static schema = schema; 33 | } 34 | -------------------------------------------------------------------------------- /src/logic/commands/generate/extractTypeDefinitionsFromDeclarations/extractTypeDefinitionsFromDeclarations.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DatabaseLanguage, 3 | QueryDeclaration, 4 | ResourceDeclaration, 5 | } from '../../../../domain'; 6 | import { getTypeDefinitionFromDeclarationWithHelpfulError } from './getTypeDefinitionFromDeclarationWithHelpfulError'; 7 | import { grabTypeDefinitionsForReferencedDatabaseProvidedFunctions } from './grabTypeDefinitionsForReferencedDatabaseProvidedFunctions/grabTypeDefinitionsForReferencedDatabaseProvidedFunctions'; 8 | 9 | export const extractTypeDefinitionsFromDeclarations = ({ 10 | language, 11 | declarations, 12 | }: { 13 | language: DatabaseLanguage; 14 | declarations: (ResourceDeclaration | QueryDeclaration)[]; 15 | }) => { 16 | // 1. grab definitions for all of the sql user has declared 17 | const definitions = declarations.map((declaration) => 18 | getTypeDefinitionFromDeclarationWithHelpfulError({ declaration }), 19 | ); 20 | 21 | // 2. pick out definitions for functions user is using that come built in w/ their language 22 | const referencedDbProvidedFunctionDefinintions = 23 | grabTypeDefinitionsForReferencedDatabaseProvidedFunctions({ 24 | definitions, 25 | language, 26 | }); 27 | 28 | // 3. return all necessary type definitions 29 | return [...definitions, ...referencedDbProvidedFunctionDefinintions]; 30 | }; 31 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/functions/upsert_image.mysql.sql: -------------------------------------------------------------------------------- 1 | /* 2 | generated by https://github.com/uladkasach/sql-schema-generator @v0.17.1 3 | */ 4 | CREATE FUNCTION `upsert_image`( 5 | in_url varchar(190), 6 | in_caption varchar(190), 7 | in_credit varchar(190), 8 | in_alt_text varchar(190) 9 | ) RETURNS bigint(20) 10 | BEGIN 11 | -- declarations 12 | DECLARE v_static_id BIGINT; 13 | DECLARE v_created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6); -- define a common created_at timestamp to use 14 | 15 | -- find or create the static entity 16 | SET v_static_id = ( 17 | SELECT id 18 | FROM image 19 | WHERE 1=1 20 | AND (url = BINARY in_url) 21 | AND (caption = BINARY in_caption OR (caption IS null AND in_caption IS null)) 22 | AND (credit = BINARY in_credit OR (credit IS null AND in_credit IS null)) 23 | AND (alt_text = BINARY in_alt_text OR (alt_text IS null AND in_alt_text IS null)) 24 | ); 25 | IF (v_static_id IS NULL) THEN -- if entity could not be found originally, create the static entity 26 | INSERT INTO image 27 | (uuid, created_at, url, caption, credit, alt_text) 28 | VALUES 29 | (uuid(), v_created_at, in_url, in_caption, in_credit, in_alt_text); 30 | SET v_static_id = ( 31 | SELECT last_insert_id() 32 | ); 33 | END IF; 34 | 35 | -- return the static entity id 36 | return v_static_id; 37 | END 38 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/exampleProject/mysql/schema/functions/upsert_image.sql: -------------------------------------------------------------------------------- 1 | /* 2 | generated by https://github.com/uladkasach/schema-generator 3 | */ 4 | CREATE FUNCTION `upsert_image`( 5 | in_url varchar(190), 6 | in_caption varchar(190), 7 | in_credit varchar(190), 8 | in_alt_text varchar(190) 9 | ) RETURNS bigint(20) 10 | BEGIN 11 | -- declarations 12 | DECLARE v_static_id BIGINT; 13 | DECLARE v_created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6); -- define a common created_at timestamp to use 14 | 15 | -- find or create the static entity 16 | SET v_static_id = ( 17 | SELECT id 18 | FROM image 19 | WHERE 1=1 20 | AND (url = BINARY in_url) 21 | AND (caption = BINARY in_caption OR (caption IS null AND in_caption IS null)) 22 | AND (credit = BINARY in_credit OR (credit IS null AND in_credit IS null)) 23 | AND (alt_text = BINARY in_alt_text OR (alt_text IS null AND in_alt_text IS null)) 24 | ); 25 | IF (v_static_id IS NULL) THEN -- if entity could not be found originally, create the static entity 26 | INSERT INTO image 27 | (uuid, created_at, url, caption, credit, alt_text) 28 | VALUES 29 | (uuid(), v_created_at, in_url, in_caption, in_credit, in_alt_text); 30 | SET v_static_id = ( 31 | SELECT last_insert_id() 32 | ); 33 | END IF; 34 | 35 | -- return the static entity id 36 | return v_static_id; 37 | END 38 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/flattenSqlByReferencingAndTokenizingSubqueries/flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive.test.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from '../../../../../deps'; 2 | import { extractSqlFromFile } from '../../../../common/extractSqlFromFile'; 3 | import { breakSqlIntoNestedSqlArraysAtParentheses } from './breakSqlIntoNestedSqlArraysAtParentheses'; 4 | import { flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive } from './flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive'; 5 | 6 | jest.mock('../../../../../deps'); 7 | const uuidMock = uuid as any as jest.Mock; 8 | uuidMock.mockReturnValue('__UUID__'); 9 | 10 | describe('flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive', () => { 11 | it('should accurately flatten and tokenize this example', async () => { 12 | const sql = await extractSqlFromFile({ 13 | filePath: `${__dirname}/../../__test_assets__/find_with_subselect_in_select_expressions.sql`, 14 | }); 15 | const nestedSqlArray = breakSqlIntoNestedSqlArraysAtParentheses({ sql }); 16 | const { references, flattenedSql } = 17 | flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive({ 18 | sqlOrNestedSqlArray: nestedSqlArray, 19 | }); 20 | expect(references.length).toEqual(1); 21 | expect(references).toMatchSnapshot(); 22 | expect(flattenedSql).toMatchSnapshot(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractSelectExpressionsFromQuerySql/extractTypeDefinitionReferenceFromSubqueryReferenceToken.test.ts: -------------------------------------------------------------------------------- 1 | import { TypeDefinitionReference } from '../../../../domain'; 2 | import { SqlSubqueryReference } from '../../../../domain/objects/SqlSubqueryReference'; 3 | import { extractTypeDefinitionReferenceFromSubqueryReferenceToken } from './extractTypeDefinitionReferenceFromSubqueryReferenceToken'; 4 | 5 | describe('extractTypeDefinitionReferenceFromSubqueryReferenceToken', () => { 6 | it('should return the reference type of the only select expression of the correct subquery', () => { 7 | const ref = extractTypeDefinitionReferenceFromSubqueryReferenceToken({ 8 | subqueryReferenceToken: '__SSQ:70bebe49-fafa-4d0d-a457-e063b40037f1__', 9 | subqueries: [ 10 | new SqlSubqueryReference({ 11 | id: '70bebe49-fafa-4d0d-a457-e063b40037f1', 12 | sql: ` 13 | ( 14 | SELECT GROUP_CONCAT(ice_cream_to_ingredient.ingredient_id ORDER BY ice_cream_to_ingredient.ingredient_id) 15 | FROM ice_cream_to_ingredient WHERE ice_cream_to_ingredient.ice_cream_id = s.id 16 | ) 17 | `.trim(), 18 | }), 19 | ], 20 | }); 21 | expect(ref).toEqual( 22 | new TypeDefinitionReference({ 23 | functionReferencePath: 'group_concat.output', 24 | tableReferencePath: null, 25 | }), 26 | ); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/getTypeDefinitionFromResourceDeclaration.ts: -------------------------------------------------------------------------------- 1 | import { ResourceDeclaration, ResourceType } from '../../../domain'; 2 | import { extractResourceTypeAndNameFromDDL } from './extractResourceTypeAndNameFromDDL'; 3 | import { extractTypeDefinitionFromFunctionSql } from './function/extractTypeDefinitionFromFunctionSql'; 4 | import { extractTypeDefinitionFromTableSql } from './table/extractTypeDefinitionFromTableSql'; 5 | import { extractTypeDefinitionFromViewSql } from './view/extractTypeDefinitionFromViewSql'; 6 | 7 | export const getTypeDefinitionFromResourceDeclaration = ({ 8 | declaration, 9 | }: { 10 | declaration: ResourceDeclaration; 11 | }) => { 12 | // 1. get name and type of resource 13 | const { name, type } = extractResourceTypeAndNameFromDDL({ 14 | ddl: declaration.sql, 15 | }); 16 | 17 | // 2. based on type, get the type definition 18 | if (type === ResourceType.TABLE) { 19 | return extractTypeDefinitionFromTableSql({ 20 | name, 21 | sql: declaration.sql, 22 | }); 23 | } 24 | if (type === ResourceType.FUNCTION) { 25 | return extractTypeDefinitionFromFunctionSql({ 26 | name, 27 | sql: declaration.sql, 28 | }); 29 | } 30 | if (type === ResourceType.VIEW) { 31 | return extractTypeDefinitionFromViewSql({ 32 | name, 33 | sql: declaration.sql, 34 | }); 35 | } 36 | throw new Error(`resource type '${type}' is not yet supported`); 37 | }; 38 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/resource/view/__snapshots__/defineTypescriptTypesForView.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`defineTypescriptTypesForView should generate an accurate looking interface for a view joining three tables and selecting from two 1`] = ` 4 | "export interface SqlViewImage { 5 | id: SqlTableSuggestion['id']; 6 | uuid: SqlTableSuggestion['uuid']; 7 | suggestion_source: SqlTableSuggestion['suggestion_source']; 8 | external_id: SqlTableSuggestion['external_id']; 9 | status: SqlTableSuggestionVersion['status']; 10 | result: SqlTableSuggestionVersion['result']; 11 | created_at: SqlTableSuggestion['created_at']; 12 | effective_at: SqlTableSuggestionVersion['effective_at']; 13 | updated_at: SqlTableSuggestionVersion['created_at']; 14 | }" 15 | `; 16 | 17 | exports[`defineTypescriptTypesForView should generate an accurate looking interface for another view joining three tables and selecting from two 1`] = ` 18 | "export interface SqlViewImage { 19 | id: SqlTableJob['id']; 20 | uuid: SqlTableJob['uuid']; 21 | title: SqlTableJob['title']; 22 | status: SqlTableJob['status']; 23 | description: SqlTableJob['description']; 24 | location_id: SqlTableJobVersion['location_id']; 25 | photo_ids: SqlFunctionArrayAggOutput; 26 | created_at: SqlTableJob['created_at']; 27 | effective_at: SqlTableJobVersion['effective_at']; 28 | updated_at: SqlTableJobVersion['created_at']; 29 | }" 30 | `; 31 | -------------------------------------------------------------------------------- /src/logic/commands/generate/defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions/defineTypescriptExecuteQueryWithBestPracticesFunction.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseLanguage } from '../../../../domain'; 2 | 3 | export const defineTypescriptExecuteQueryWithBestPracticesFunction = ({ 4 | language, 5 | }: { 6 | language: DatabaseLanguage; 7 | }) => { 8 | return ` 9 | // utility used by each query function 10 | export const executeQueryWithBestPractices = async ({ 11 | dbExecute, 12 | logDebug, 13 | name, 14 | sql, 15 | input, 16 | }: { 17 | dbExecute: DatabaseExecuteCommand; 18 | logDebug: LogMethod; 19 | name: string; 20 | sql: string; 21 | input: object | null; 22 | }) => { 23 | // 1. define the query with yesql 24 | const { ${ 25 | language === DatabaseLanguage.POSTGRES ? 'text' : 'sql' // `prepare` returns { sql, values } or { text, values } depending on `language` 26 | }: preparedSql, values: preparedValues } = prepare(sql)(input || {}); 27 | 28 | // 2. log that we're running the request 29 | logDebug(\`\${name}.input\`, { input }); 30 | 31 | // 3. execute the query 32 | const ${ 33 | language === DatabaseLanguage.POSTGRES ? '{ rows: output }' : '[output]' 34 | } = await dbExecute({ sql: preparedSql, values: preparedValues }); 35 | 36 | // 4. log that we've executed the request 37 | logDebug(\`\${name}.output\`, { output }); 38 | 39 | // 5. return the output 40 | return output; 41 | }; 42 | `.trim(); 43 | }; 44 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/common/castCommasInParensToPipesForTokenSafety.ts: -------------------------------------------------------------------------------- 1 | export const castCommasInParensToPipesForTokenSafety = ({ 2 | sql, 3 | }: { 4 | sql: string; 5 | }) => { 6 | // track state of whether we are in parenthesizes or not 7 | let areInsideParensDepth = 0; // init as 0, since not inside parens 8 | const areInsideParens = () => areInsideParensDepth > 0; // whenever areInsideParensDepth > 0, we are inside parens 9 | 10 | // init object to build up the "casted" string 11 | let castedString = ''; 12 | 13 | // loop through each char and update state / replace char as needed 14 | [...sql].forEach((char) => { 15 | // 1. manage "areInsideParens" state 16 | if (char === '(') { 17 | areInsideParensDepth += 1; // one parens deeper! 18 | } 19 | if (char === ')') { 20 | if (!areInsideParens) 21 | throw new Error( 22 | 'exiting parenthesizes without having opened them; not supported', 23 | ); // fail fast for unsupported case 24 | areInsideParensDepth -= 1; // mark that we've dropped off one parens now 25 | } 26 | 27 | // 2. cast the char, considering whether we're in parens or not 28 | const castedChar = areInsideParens() && char === ',' ? '|' : char; // cast "," if we are inside parens 29 | 30 | // 3. add casted char to string 31 | castedString += castedChar; 32 | }); 33 | 34 | // return the casted string 35 | return castedString; 36 | }; 37 | -------------------------------------------------------------------------------- /src/logic/config/getConfig/readConfig/readConfig.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { GeneratorConfig } from '../../../../domain'; 2 | import { TEST_ASSETS_ROOT_DIR } from '../../../__test_assets__/directory'; 3 | import { readConfig } from './readConfig'; 4 | 5 | describe('readConfig', () => { 6 | it('should be able to read a fully declared config', async () => { 7 | const config = await readConfig({ 8 | filePath: `${TEST_ASSETS_ROOT_DIR}/exampleProject/mysql/codegen.sql.yml`, 9 | }); 10 | expect(config).toBeInstanceOf(GeneratorConfig); 11 | expect(config.language).toEqual('mysql'); 12 | expect(config.dialect).toEqual('5.7'); 13 | expect(config.declarations.length).toEqual(12); 14 | expect({ ...config, rootDir: '__DIR__' }).toMatchSnapshot(); // to log an example of the output; note we mask dir to make it machine independent 15 | }); 16 | it('should be able to read a config without queries', async () => { 17 | const config = await readConfig({ 18 | filePath: `${TEST_ASSETS_ROOT_DIR}/exampleProject/postgres-noqueries/codegen.sql.yml`, 19 | }); 20 | expect(config).toBeInstanceOf(GeneratorConfig); 21 | expect(config.language).toEqual('postgres'); 22 | expect(config.dialect).toEqual('10.7'); 23 | expect(config.declarations.length).toEqual(3); 24 | expect({ ...config, rootDir: '__DIR__' }).toMatchSnapshot(); // to log an example of the output; note we mask dir to make it machine independent 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/domain/constants.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TypeDefinitionOfQuery, 3 | TypeDefinitionOfResourceFunction, 4 | TypeDefinitionOfResourceTable, 5 | TypeDefinitionOfResourceView, 6 | } from './objects'; 7 | 8 | export enum DatabaseLanguage { 9 | MYSQL = 'mysql', 10 | POSTGRES = 'postgres', 11 | } 12 | export enum DefinitionType { 13 | RESOURCE = 'resource', 14 | QUERY = 'query', 15 | } 16 | export enum DataType { 17 | STRING = 'string', 18 | STRING_ARRAY = 'string[]', // postgres supports arrays 19 | NUMBER = 'number', 20 | NUMBER_ARRAY = 'number[]', // postgres supports arrays 21 | JSON = 'Record', 22 | JSON_ARRAY = 'Record[]', // postgres supports arrays 23 | DATE = 'Date', 24 | BUFFER = 'Buffer', // e.g., for binary 25 | BOOLEAN = 'boolean', 26 | NULL = 'null', 27 | UNDEFINED = 'undefined', 28 | } 29 | export enum ResourceType { 30 | TABLE = 'TABLE', 31 | FUNCTION = 'FUNCTION', 32 | PROCEDURE = 'PROCEDURE', 33 | VIEW = 'VIEW', 34 | } 35 | export enum QuerySection { 36 | SELECT_EXPRESSIONS = 'SELECT_EXPRESSIONS', 37 | TABLE_REFERENCES = 'TABLE_REFERENCES', 38 | WHERE_CONDITIONS = 'WHERE_CONDITIONS', 39 | } 40 | export type TypeDefinition = 41 | | TypeDefinitionOfQuery 42 | | TypeDefinitionOfResourceTable 43 | | TypeDefinitionOfResourceFunction 44 | | TypeDefinitionOfResourceView; 45 | export interface GeneratedOutputPaths { 46 | types: string; 47 | queryFunctions: string | undefined; // undefined -> user does not want them 48 | } 49 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/flattenSqlByReferencingAndTokenizingSubqueries/extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from '../../../../../deps'; 2 | import { SqlSubqueryReference } from '../../../../../domain/objects/SqlSubqueryReference'; 3 | import { getTokenForSqlSubqueryReference } from './getTokenForSubqueryReference'; 4 | 5 | export const extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings = ({ 6 | sqlParts, 7 | }: { 8 | sqlParts: string[]; 9 | }) => { 10 | const references: SqlSubqueryReference[] = []; 11 | 12 | // 1. for each part of the sql array, create a reference if it matches "query" pattern 13 | const referencedSqlParts = sqlParts.map((sql) => { 14 | const matchesQueryPattern = new RegExp(/^\(\s*select(?:.|\s)*\)$/i).test( 15 | sql, 16 | ); 17 | // if it matches the pattern for being a "query", then return swap it out with a reference 18 | if (matchesQueryPattern) { 19 | const reference = new SqlSubqueryReference({ 20 | id: uuid().replace(/-/g, ''), // uuid without dashes, important for downstream logic 21 | sql, 22 | }); 23 | references.push(reference); 24 | return getTokenForSqlSubqueryReference({ reference }); 25 | } 26 | 27 | // if it does not match the pattern for being a query, then do nothing with it 28 | return sql; 29 | }); 30 | 31 | // 2. return the referenced sql parts + references 32 | return { 33 | referencedSqlParts, 34 | references, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractTypeDefinitionFromQuerySql.ts: -------------------------------------------------------------------------------- 1 | import strip from 'sql-strip-comments'; 2 | 3 | import { TypeDefinitionOfQuery } from '../../../domain/objects/TypeDefinitionOfQuery'; 4 | import { extractInputVariablesFromQuerySql } from './extractInputVariablesFromQuerySql/extractInputVariablesFromQuerySql'; 5 | import { extractSelectExpressionsFromQuerySql } from './extractSelectExpressionsFromQuerySql/extractSelectExpressionsFromQuerySql'; 6 | import { extractTableReferencesFromQuerySql } from './extractTableReferencesFromQuerySql/extractTableReferencesFromQuerySql'; 7 | 8 | export const extractTypeDefinitionFromQuerySql = ({ 9 | name, 10 | path, 11 | sql, 12 | }: { 13 | name: string; 14 | path: string; 15 | sql: string; 16 | }) => { 17 | // 0. strip out comments 18 | const strippedSql: string = strip(sql); 19 | 20 | // 1. grab the selectExpression definitions 21 | const selectExpressions = extractSelectExpressionsFromQuerySql({ 22 | sql: strippedSql, 23 | }); 24 | 25 | // 2. grab the table reference definitions 26 | const tableReferences = extractTableReferencesFromQuerySql({ 27 | sql: strippedSql, 28 | }); 29 | 30 | // 3. grab the input variable definitions 31 | const inputVariables = extractInputVariablesFromQuerySql({ 32 | sql: strippedSql, 33 | }); 34 | 35 | // 4. return the full query typedef 36 | return new TypeDefinitionOfQuery({ 37 | name, 38 | path, 39 | selectExpressions, 40 | tableReferences, 41 | inputVariables, 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/find_train_by_id.sql: -------------------------------------------------------------------------------- 1 | -- query_name = find_train_by_id 2 | SELECT 3 | train.id, 4 | train.uuid, 5 | ( 6 | SELECT json_build_object( 7 | 'id', geocode.id, 8 | 'uuid', geocode.uuid, 9 | 'latitude', geocode.latitude, 10 | 'longitude', geocode.longitude 11 | ) AS json_build_object 12 | FROM geocode WHERE geocode.id = train.home_station_geocode_id 13 | ) AS home_station_geocode, 14 | train.combination_id, 15 | ( 16 | SELECT COALESCE(array_agg(locomotive.uuid ORDER BY locomotive_ref.array_order_index), array[]::varchar[]) AS array_agg 17 | FROM locomotive 18 | JOIN unnest(train.locomotive_ids) WITH ORDINALITY 19 | AS locomotive_ref (id, array_order_index) 20 | ON locomotive.id = locomotive_ref.id 21 | ) AS locomotive_uuids, 22 | ( 23 | SELECT COALESCE(array_agg(carriage.uuid ORDER BY carriage_ref.array_order_index), array[]::varchar[]) AS array_agg 24 | FROM carriage 25 | JOIN unnest(train.carriage_ids) WITH ORDINALITY 26 | AS carriage_ref (id, array_order_index) 27 | ON carriage.id = carriage_ref.id 28 | ) AS carriage_uuids, 29 | ( 30 | SELECT COALESCE(array_agg(train_engineer.uuid ORDER BY train_engineer_ref.array_order_index), array[]::varchar[]) AS array_agg 31 | FROM train_engineer 32 | JOIN unnest(train.engineer_ids) WITH ORDINALITY 33 | AS train_engineer_ref (id, array_order_index) 34 | ON train_engineer.id = train_engineer_ref.id 35 | ) AS engineer_uuids, 36 | train.status 37 | FROM view_train_current AS train 38 | WHERE train.id = :id; 39 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/resource/view/defineTypescriptTypesForView.ts: -------------------------------------------------------------------------------- 1 | import { ResourceType, TypeDefinition } from '../../../../domain'; 2 | import { TypeDefinitionOfResourceView } from '../../../../domain/objects/TypeDefinitionOfResourceView'; 3 | import { castResourceNameToTypescriptTypeName } from '../../common/castResourceNameToTypescriptTypeName'; 4 | import { defineTypescriptTypeFromReference } from '../../common/defineTypescriptTypeFromReference/defineTypescriptTypeFromReference'; 5 | 6 | export const defineTypescriptTypesForView = ({ 7 | definition, 8 | allDefinitions, 9 | }: { 10 | definition: TypeDefinitionOfResourceView; 11 | allDefinitions: TypeDefinition[]; 12 | }) => { 13 | // define column types in typescript format 14 | const typescriptInterfaceColumnDefinitions = definition.selectExpressions.map( 15 | (selectExpression) => { 16 | const typescriptTypeForReference = defineTypescriptTypeFromReference({ 17 | reference: selectExpression.typeReference, 18 | queryTableReferences: definition.tableReferences, 19 | typeDefinitions: allDefinitions, 20 | }); 21 | return `${selectExpression.alias}: ${typescriptTypeForReference};`; 22 | }, 23 | ); 24 | 25 | // output 26 | const typescriptInterfaceDefinition = ` 27 | export interface ${castResourceNameToTypescriptTypeName({ 28 | name: definition.name, 29 | resourceType: ResourceType.VIEW, 30 | })} { 31 | ${typescriptInterfaceColumnDefinitions.join('\n ')} 32 | } 33 | `.trim(); 34 | 35 | // return typescript types 36 | return typescriptInterfaceDefinition; 37 | }; 38 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/function/extractInputsFromFunctionSql/extractInputsFromFunctionSql.ts: -------------------------------------------------------------------------------- 1 | import strip from 'sql-strip-comments'; 2 | 3 | import { castCommasInParensToPipesForTokenSafety } from '../../common/castCommasInParensToPipesForTokenSafety'; 4 | import { extractTypeDefinitionFromFunctionInputSql } from './extractTypeDefinitionFromFunctionInputSql'; 5 | 6 | export const extractInputsFromFunctionSql = ({ sql }: { sql: string }) => { 7 | // 0. strip comments 8 | const strippedSql: string = strip(sql); 9 | 10 | // 1. grab the insides of the inputs definition 11 | const sqlBeforeReturns = strippedSql.split(/(?:returns|RETURNS)/)[0]!; 12 | const innerSqlAndAfter = sqlBeforeReturns.split('(').slice(1).join('('); // drop the part before the first '(' 13 | const innerSql = innerSqlAndAfter.split(')').slice(0, -1).join(')'); // drop the part after the last ')' 14 | 15 | // 2. cast commas inside of parens into pipes, so that we treat them as unique tokens when splitting "property lines" by comma 16 | const parenTokenizedInnerSql = castCommasInParensToPipesForTokenSafety({ 17 | sql: innerSql, 18 | }); // particularly useful for enum typedefs 19 | 20 | // 3. grab definition lines, by splitting out properties by commas 21 | const functionInputSqlSet = parenTokenizedInnerSql 22 | .split(',') 23 | .map((str) => str.trim()) // trim the line 24 | .filter((str) => !!str); // skip blank lines 25 | 26 | // 4. get column definition from each property 27 | return functionInputSqlSet.map((sql) => 28 | extractTypeDefinitionFromFunctionInputSql({ sql }), 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/getTypescriptTypesFromTypeDefinition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TypeDefinitionOfQuery, 3 | TypeDefinitionOfResourceFunction, 4 | TypeDefinitionOfResourceTable, 5 | TypeDefinitionOfResourceView, 6 | TypeDefinition, 7 | } from '../../domain'; 8 | import { defineTypescriptTypesForQuery } from './query/defineTypescriptTypesForQuery'; 9 | import { defineTypescriptTypesForFunction } from './resource/function/defineTypescriptTypesForFunction'; 10 | import { defineTypescriptTypesForTable } from './resource/table/defineTypescriptTypesForTable'; 11 | import { defineTypescriptTypesForView } from './resource/view/defineTypescriptTypesForView'; 12 | 13 | export const getTypescriptTypesFromTypeDefinition = ({ 14 | definition, 15 | allDefinitions, 16 | }: { 17 | definition: TypeDefinition; 18 | allDefinitions: TypeDefinition[]; 19 | }) => { 20 | if (definition instanceof TypeDefinitionOfQuery) { 21 | return defineTypescriptTypesForQuery({ 22 | definition, 23 | allDefinitions, 24 | }); 25 | } 26 | if (definition instanceof TypeDefinitionOfResourceTable) { 27 | return defineTypescriptTypesForTable({ 28 | definition, 29 | }); 30 | } 31 | if (definition instanceof TypeDefinitionOfResourceFunction) { 32 | return defineTypescriptTypesForFunction({ 33 | definition, 34 | }); 35 | } 36 | if (definition instanceof TypeDefinitionOfResourceView) { 37 | return defineTypescriptTypesForView({ 38 | definition, 39 | allDefinitions, 40 | }); 41 | } 42 | throw new Error('unexpected definition type'); // fail fast, this should never occur 43 | }; 44 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/view/extractTypeDefinitionFromViewSql.test.ts: -------------------------------------------------------------------------------- 1 | import { extractSqlFromFile } from '../../../common/extractSqlFromFile'; 2 | import { extractTypeDefinitionFromViewSql } from './extractTypeDefinitionFromViewSql'; 3 | 4 | describe('extractTypeDefinitionFromViewSql', () => { 5 | it('should be able to extract types in this example', async () => { 6 | const exampleSql = await extractSqlFromFile({ 7 | filePath: `${__dirname}/../../../__test_assets__/views/view_suggestion_current.sql`, 8 | }); 9 | const typeDef = extractTypeDefinitionFromViewSql({ 10 | name: 'view_suggestion_current', 11 | sql: exampleSql, 12 | }); 13 | expect(typeDef).toMatchSnapshot(); 14 | }); 15 | it('should be able to extract types in this other example', async () => { 16 | const exampleSql = await extractSqlFromFile({ 17 | filePath: `${__dirname}/../../../__test_assets__/views/view_job_current.sql`, 18 | }); 19 | const typeDef = extractTypeDefinitionFromViewSql({ 20 | name: 'view_job_current', 21 | sql: exampleSql, 22 | }); 23 | expect(typeDef).toMatchSnapshot(); 24 | }); 25 | it('should be able to extract types even when one of the columns has the word "from" in it', async () => { 26 | const exampleSql = await extractSqlFromFile({ 27 | filePath: `${__dirname}/../../../__test_assets__/views/view_email_current.sql`, 28 | }); 29 | const typeDef = extractTypeDefinitionFromViewSql({ 30 | name: 'view_email_current', 31 | sql: exampleSql, 32 | }); 33 | expect(typeDef).toMatchSnapshot(); 34 | }); 35 | it.todo('should throw an error if query has input variables'); 36 | }); 37 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/common/defineTypescriptTypeFromReference/defineTypescriptTypeFromFunctionReference.ts: -------------------------------------------------------------------------------- 1 | import { ResourceType } from '../../../../domain'; 2 | import { TypeDefinitionReference } from '../../../../domain/objects/TypeDefinitionReference'; 3 | import { castResourceNameToTypescriptTypeName } from '../castResourceNameToTypescriptTypeName'; 4 | 5 | export const defineTypescriptTypeFromFunctionReference = ({ 6 | reference, 7 | }: { 8 | reference: TypeDefinitionReference; 9 | }) => { 10 | // sanity check what this is called with, to help us debug if needed 11 | if (!reference.functionReferencePath) 12 | throw new Error('expected function reference to be defined'); // fail fast 13 | 14 | // grab the function name from the reference definition 15 | const [functionName, inputOrOutput, inputPropertyIndex] = 16 | reference.functionReferencePath.split('.') as [string, string, string]; 17 | 18 | // grab the typescript name for this function 19 | const functionTypescriptName = castResourceNameToTypescriptTypeName({ 20 | name: functionName, 21 | resourceType: ResourceType.FUNCTION, 22 | }); 23 | 24 | // if its referencing the output, return that 25 | if (inputOrOutput === 'output') return `${functionTypescriptName}Output`; 26 | 27 | // if its referencing an input, return that 28 | if (inputOrOutput === 'input') 29 | return `${functionTypescriptName}Input['${inputPropertyIndex}']`; 30 | 31 | // if was neither, throw an error - it should always be one of the above 32 | throw new Error( 33 | 'type definition reference of function was not defined as "input" or "output"', 34 | ); // fail fast, this is an invalid reference 35 | }; 36 | -------------------------------------------------------------------------------- /.github/workflows/.install.yml: -------------------------------------------------------------------------------- 1 | name: .install 2 | 3 | on: 4 | workflow_call: 5 | outputs: 6 | node-modules-cache-key: 7 | description: a max(stable) cache key to the node modules of this commit's dependencies 8 | value: ${{ jobs.npm.outputs.node-modules-cache-key }} 9 | 10 | jobs: 11 | npm: 12 | runs-on: ubuntu-20.04 13 | outputs: 14 | node-modules-cache-key: ${{ steps.cache.outputs.cache-primary-key }} 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v3 18 | 19 | - name: set node-version 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version-file: '.nvmrc' 23 | 24 | - name: node-modules deps hash 25 | id: deps-hash 26 | run: | 27 | PACKAGE_DEPS_HASH=$(jq '.packages' package-lock.json | jq 'del(."".version)' | md5sum | awk '{print $1}'); 28 | echo "PACKAGE_DEPS_HASH=$PACKAGE_DEPS_HASH" 29 | echo "package-deps-hash=$PACKAGE_DEPS_HASH" >> "$GITHUB_OUTPUT" 30 | - name: node-modules cache get 31 | uses: actions/cache/restore@v3 32 | id: cache 33 | with: 34 | path: ./node_modules 35 | key: ${{ runner.os }}-node-${{ steps.deps-hash.outputs.package-deps-hash }} 36 | 37 | - name: node-modules cache miss install 38 | if: steps.cache.outputs.cache-hit != 'true' 39 | run: npm ci --ignore-scripts --prefer-offline --no-audit 40 | 41 | - name: node-modules cache set 42 | if: steps.cache.outputs.cache-hit != 'true' 43 | uses: actions/cache/save@v3 44 | with: 45 | path: ./node_modules 46 | key: ${{ steps.cache.outputs.cache-primary-key }} 47 | -------------------------------------------------------------------------------- /src/logic/__test_assets__/queries/find_train_by_uuid.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | train.id, 3 | train.uuid, 4 | ( 5 | SELECT json_build_object( 6 | 'id', geocode.id, 7 | 'uuid', geocode.uuid, 8 | 'latitude', geocode.latitude, 9 | 'longitude', geocode.longitude 10 | ) AS json_build_object 11 | FROM geocode WHERE geocode.id = train.home_station_geocode_id 12 | ) AS home_station_geocode, 13 | train.combination_id, 14 | ( 15 | SELECT COALESCE(array_agg(locomotive.uuid ORDER BY locomotive_ref.array_order_index), array[]::uuid[]) AS array_agg 16 | FROM locomotive 17 | JOIN unnest(train.locomotive_ids) WITH ORDINALITY 18 | AS locomotive_ref (id, array_order_index) 19 | ON locomotive.id = locomotive_ref.id 20 | ) AS locomotive_uuids, 21 | ( 22 | SELECT COALESCE(array_agg(carriage.uuid ORDER BY carriage_ref.array_order_index), array[]::uuid[]) AS array_agg 23 | FROM carriage 24 | JOIN unnest(train.carriage_ids) WITH ORDINALITY 25 | AS carriage_ref (id, array_order_index) 26 | ON carriage.id = carriage_ref.id 27 | ) AS carriage_uuids, 28 | ( 29 | SELECT COALESCE(array_agg(train_engineer.uuid ORDER BY train_engineer_ref.array_order_index), array[]::uuid[]) AS array_agg 30 | FROM train_engineer 31 | JOIN unnest(train.engineer_ids) WITH ORDINALITY 32 | AS train_engineer_ref (id, array_order_index) 33 | ON train_engineer.id = train_engineer_ref.id 34 | ) AS engineer_uuids, 35 | ( 36 | SELECT train_engineer.uuid 37 | FROM train_engineer WHERE train_engineer.id = train.lead_engineer_id 38 | ) AS lead_engineer_uuid, 39 | train.status 40 | FROM view_train_current AS train 41 | WHERE train.uuid = :uuid; 42 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractTableReferencesFromQuerySql/extractTableReferencesFromQuerySql.ts: -------------------------------------------------------------------------------- 1 | import { flattenSqlByReferencingAndTokenizingSubqueries } from '../common/flattenSqlByReferencingAndTokenizingSubqueries'; 2 | import { extractTypeDefinitionFromTableReference } from './extractTypeDefinitionFromTableReference'; 3 | import { tryToExtractTableReferenceSqlSet } from './tryToExtractTableReferenceSqlSetFromQuerySql'; 4 | 5 | export const extractTableReferencesFromQuerySql = ({ 6 | sql, 7 | }: { 8 | sql: string; 9 | }) => { 10 | // flatten the sql and tokenize each subquery - otherwise subquery syntax throws off subsequent processing 11 | const { flattenedSql: rootQuerySql, references: subqueries } = 12 | flattenSqlByReferencingAndTokenizingSubqueries({ 13 | sql, 14 | }); 15 | 16 | // on both the root query sql and each subquery sql (if subqueries are present) extract table references 17 | const flattenedSqlsToConsider = [ 18 | rootQuerySql, 19 | ...subqueries.map((subquery) => subquery.sql), 20 | ]; 21 | const tableReferences = flattenedSqlsToConsider 22 | .map((sql) => { 23 | // get the table reference sqls from the main query sql 24 | const tableReferenceSqlSet = tryToExtractTableReferenceSqlSet({ sql }); // "try to", since its okay if there are no table references; (e.g., `select upsert_something(...)`) 25 | 26 | // get the type definitions of table references from each table reference sql 27 | return tableReferenceSqlSet.map((sql) => 28 | extractTypeDefinitionFromTableReference({ sql }), 29 | ); 30 | }) 31 | .flat(); 32 | 33 | // return the table references 34 | return tableReferences; 35 | }; 36 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/view/extractTypeDefinitionFromViewSql.ts: -------------------------------------------------------------------------------- 1 | import { TypeDefinitionOfResourceView } from '../../../../domain/objects/TypeDefinitionOfResourceView'; 2 | import { extractTypeDefinitionFromQuerySql } from '../../query/extractTypeDefinitionFromQuerySql'; 3 | 4 | /* 5 | note: a view is effectively a named alias for a query 6 | - with the condition that the query can not have input variables 7 | 8 | therefore, the types are a subset of the query types 9 | */ 10 | export const extractTypeDefinitionFromViewSql = ({ 11 | name, 12 | sql, 13 | }: { 14 | name: string; 15 | sql: string; 16 | }) => { 17 | // 0. grab the query definition from the view; e.g.: `CREATE VIEW __NAME__ AS SELECT ....` => `SELECT ...` => typedef 18 | const querySql = sql 19 | .split(/(SELECT|select)/) 20 | .slice(1) // slice(1) since part[0] = CREATE VIEW ... AS, part[1] = SELECT, part[2] = __SELECT_BODY__ 21 | .join(''); // merge to have SELECT __SELECT_BODY__ 22 | const queryDef = extractTypeDefinitionFromQuerySql({ 23 | name, 24 | path: 'none', // we dont currently track paths for view - and it wont be a problem downstream to fake it, so put a placeholder value 25 | sql: querySql, 26 | }); 27 | 28 | // 1. check that query def does not have any inputs, as views with inputs in query are invalid 29 | if (queryDef.inputVariables.length) 30 | throw new Error(`query def for view '${name}' can not have inputs`); 31 | 32 | // 2. grab the selectExpressions and tableReferences off of the query 33 | return new TypeDefinitionOfResourceView({ 34 | name, 35 | selectExpressions: queryDef.selectExpressions, 36 | tableReferences: queryDef.tableReferences, 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/logic/config/getConfig/getAllPathsMatchingGlobs/getAllPathsMatchingGlobs.test.ts: -------------------------------------------------------------------------------- 1 | import { getAllPathsMatchingGlobs } from './getAllPathsMatchingGlobs'; 2 | 3 | const root = `${__dirname}/../../../__test_assets__/exampleProject/mysql`; // i.e., starting from the "codegen.sql.yml" 4 | 5 | describe('getAllPathsMatchingGlobs', () => { 6 | it('should return paths that match a glob', async () => { 7 | const files = await getAllPathsMatchingGlobs({ 8 | globs: ['schema/**/*.sql'], 9 | root, 10 | }); 11 | expect(files).toContain('schema/tables/image.sql'); 12 | expect(files).toContain('schema/functions/upsert_image.sql'); 13 | }); 14 | it('should return paths that match each glob', async () => { 15 | const files = await getAllPathsMatchingGlobs({ 16 | globs: ['schema/**/*.sql', 'src/dao/**/*.ts'], 17 | root, 18 | }); 19 | expect(files).toContain('schema/tables/image.sql'); 20 | expect(files).toContain('src/dao/user/findAllByName.ts'); 21 | expect(files).toContain('src/dao/user/findAllByName.test.ts'); 22 | }); 23 | it('should not return paths that match a glob that starts with "!"', async () => { 24 | const files = await getAllPathsMatchingGlobs({ 25 | globs: [ 26 | 'schema/**/*.sql', 27 | 'src/dao/**/*.ts', 28 | '!src/dao/**/*.test.ts', 29 | '!src/dao/**/*.test.integration.ts', 30 | ], 31 | root, 32 | }); 33 | expect(files).toContain('schema/tables/image.sql'); 34 | expect(files).toContain('src/dao/user/findAllByName.ts'); 35 | expect(files).not.toContain('src/dao/user/findAllByName.test.ts'); 36 | expect(files).not.toContain( 37 | 'src/dao/user/findAllByName.test.integration.ts', 38 | ); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/common/defineTypescriptTypeFromReference/defineTypescriptTypeFromReference.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TypeDefinitionOfQueryTableReference, 3 | TypeDefinition, 4 | } from '../../../../domain'; 5 | import { TypeDefinitionReference } from '../../../../domain/objects/TypeDefinitionReference'; 6 | import { defineTypescriptTypeFromFunctionReference } from './defineTypescriptTypeFromFunctionReference'; 7 | import { defineTypescriptTypeFromTableReference } from './defineTypescriptTypeFromTableReference'; 8 | 9 | export const defineTypescriptTypeFromReference = ({ 10 | reference, 11 | queryTableReferences, 12 | typeDefinitions, 13 | }: { 14 | reference: TypeDefinitionReference; 15 | 16 | /** 17 | * table references are required, as queries can define custom aliases per table 18 | */ 19 | queryTableReferences: TypeDefinitionOfQueryTableReference[]; 20 | 21 | /** 22 | * type definitions are required, as table references can reference tables _or_ views - and we have to determine which one 23 | */ 24 | typeDefinitions: TypeDefinition[]; 25 | }) => { 26 | // if its a table reference, return the table reference 27 | if (reference.tableReferencePath) { 28 | return defineTypescriptTypeFromTableReference({ 29 | reference, 30 | queryTableReferences, 31 | typeDefinitions, 32 | }); 33 | } 34 | 35 | // if its a function reference, return the function reference 36 | if (reference.functionReferencePath) { 37 | return defineTypescriptTypeFromFunctionReference({ reference }); 38 | } 39 | 40 | // otherwise, we don't know how to handle this case 41 | throw new Error( 42 | 'type definition reference does not reference a table or function; unexpected', 43 | ); // fail fast 44 | }; 45 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/resource/table/defineTypescriptTypesForTable.test.ts: -------------------------------------------------------------------------------- 1 | import { extractSqlFromFile } from '../../../common/extractSqlFromFile'; 2 | import { extractTypeDefinitionFromTableSql } from '../../../sqlToTypeDefinitions/resource/table/extractTypeDefinitionFromTableSql'; 3 | import { defineTypescriptTypesForTable } from './defineTypescriptTypesForTable'; 4 | 5 | describe('defineTypescriptTypesForTable', () => { 6 | it('should generate an accurate looking interface for this table definition', async () => { 7 | const definition = extractTypeDefinitionFromTableSql({ 8 | name: 'image', 9 | sql: await extractSqlFromFile({ 10 | filePath: `${__dirname}/../../../__test_assets__/tables/image.mysql.sql`, 11 | }), 12 | }); 13 | const code = defineTypescriptTypesForTable({ definition }); 14 | expect(code).toMatchSnapshot(); 15 | }); 16 | it('should generate an accurate looking interface for this other table definition', async () => { 17 | const definition = extractTypeDefinitionFromTableSql({ 18 | name: 'suggestion_version', 19 | sql: await extractSqlFromFile({ 20 | filePath: `${__dirname}/../../../__test_assets__/tables/suggestion_version.mysql.sql`, 21 | }), 22 | }); 23 | const code = defineTypescriptTypesForTable({ definition }); 24 | expect(code).toMatchSnapshot(); 25 | }); 26 | it('should generate an accurate looking interface for this other table definition, again', async () => { 27 | const definition = extractTypeDefinitionFromTableSql({ 28 | name: 'job_version', 29 | sql: await extractSqlFromFile({ 30 | filePath: `${__dirname}/../../../__test_assets__/tables/job_version.postgres.sql`, 31 | }), 32 | }); 33 | const code = defineTypescriptTypesForTable({ definition }); 34 | expect(code).toMatchSnapshot(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/logic/commands/generate/defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions/defineTypescriptImportGeneratedTypesCodeForQueryFunctions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GeneratedOutputPaths, 3 | TypeDefinitionOfQuery, 4 | } from '../../../../domain'; 5 | import { defineTypescriptQueryFunctionForQuery } from '../../../typeDefinitionsToCode/query/defineTypescriptQueryFunctionForQuery'; 6 | import { QueryFunctionsOutputPathNotDefinedButRequiredError } from './QueryFunctionsOutputPathNotDefinedError'; 7 | import { getRelativePathFromFileToFile } from './utils/getRelativePathFromFileToFile'; 8 | 9 | export const defineTypescriptImportGeneratedTypesCodeForQueryFunctions = ({ 10 | queryDefinitions, 11 | generatedOutputPaths, 12 | }: { 13 | queryDefinitions: TypeDefinitionOfQuery[]; 14 | generatedOutputPaths: GeneratedOutputPaths; 15 | }) => { 16 | // check that the query functions output path was defined; if it was not, this code path should not have been called so fail fast 17 | if (!generatedOutputPaths.queryFunctions) 18 | throw new QueryFunctionsOutputPathNotDefinedButRequiredError(); 19 | 20 | // define all of the imports needed 21 | const generatedTypesToImport = queryDefinitions 22 | .map( 23 | (def) => 24 | defineTypescriptQueryFunctionForQuery({ name: def.name }).imports 25 | .generatedTypes, 26 | ) 27 | .flat() 28 | .sort(); 29 | 30 | // define the path to the generated imports file from the generated query functions file 31 | const pathToGeneratedImports = getRelativePathFromFileToFile({ 32 | fromFile: generatedOutputPaths.queryFunctions, 33 | toFile: generatedOutputPaths.types, 34 | }); 35 | 36 | // define the import code 37 | const importCode = ` 38 | import { 39 | ${generatedTypesToImport.join(',\n ')}, 40 | } from '${pathToGeneratedImports}'; 41 | `.trim(); 42 | 43 | // return it 44 | return importCode; 45 | }; 46 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/extractResourceTypeAndNameFromDDL.ts: -------------------------------------------------------------------------------- 1 | import { ResourceType } from '../../../domain'; 2 | 3 | // TODO: generalize to other databases with adapter pattern 4 | const MYSQL_TYPE_NAME_CAPTURE_REGEX = 5 | /(?:CREATE|create)(?: OR REPLACE)?(?:\s+)(?:DEFINER=`[a-zA-Z0-9_]+`@`[a-zA-Z0-9_%]+`)?(?:\s*)(PROCEDURE|procedure|FUNCTION|function|TABLE|table|VIEW|view)(?:\s+)(?:`?)(\w+)(?:`?)(?:\s*)(?:\(|AS|as)/g; // captures type and name from create statements of resources 6 | 7 | const regexTypeMatchToTypeEnum = { 8 | PROCEDURE: ResourceType.PROCEDURE, 9 | procedure: ResourceType.PROCEDURE, 10 | FUNCTION: ResourceType.FUNCTION, 11 | function: ResourceType.FUNCTION, 12 | TABLE: ResourceType.TABLE, 13 | table: ResourceType.TABLE, 14 | VIEW: ResourceType.VIEW, 15 | view: ResourceType.VIEW, 16 | }; 17 | type RegexTypeMatchOption = 18 | | 'PROCEDURE' 19 | | 'procedure' 20 | | 'FUNCTION' 21 | | 'function' 22 | | 'TABLE' 23 | | 'table' 24 | | 'VIEW' 25 | | 'view'; 26 | export const extractResourceTypeAndNameFromDDL = ({ ddl }: { ddl: string }) => { 27 | // get name and type with regex 28 | const captureRegex = new RegExp(MYSQL_TYPE_NAME_CAPTURE_REGEX, 'gm'); // note, we reinstantiate so as to not share regex object (i.e., state) between calls 29 | const extractionMatches = captureRegex.exec(ddl); 30 | 31 | // if no matches, throw error 32 | if (!extractionMatches) 33 | throw new Error( 34 | 'resource creation type and name could not be found in ddl', 35 | ); 36 | 37 | // format the type 38 | const regexTypeMatch = extractionMatches[1] as RegexTypeMatchOption; // the first capture group is the type match 39 | const type = regexTypeMatchToTypeEnum[regexTypeMatch]; 40 | 41 | // extract the name 42 | const name = extractionMatches[2]!; 43 | 44 | // return type and name 45 | return { 46 | type, 47 | name, 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /src/logic/commands/generate/extractTypeDefinitionsFromDeclarations/getTypeDefinitionFromDeclarationWithHelpfulError.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import { ResourceDeclaration, QueryDeclaration } from '../../../../domain'; 4 | import { getTypeDefinitionFromDeclaration } from '../../../sqlToTypeDefinitions/getTypeDefinitionFromDeclaration'; 5 | 6 | class ErrorExtractingTypeDefinitionFromDeclaration extends Error { 7 | constructor({ 8 | declaration, 9 | error, 10 | }: { 11 | declaration: ResourceDeclaration | QueryDeclaration; 12 | error: Error; 13 | }) { 14 | const declarationTypeCommonName = 15 | declaration instanceof QueryDeclaration ? 'query' : 'resource'; 16 | const message = ` 17 | Error: Could not extract type definition from sql of ${declarationTypeCommonName} from file '${declaration.path}': ${error.message} 18 | 19 | You can fix the error by correcting the sql in '${declaration.path}' 20 | `.trim(); 21 | super(message); 22 | this.stack = error.stack?.replace(error.message, message); // swap out the stack 23 | } 24 | } 25 | 26 | /** 27 | * wraps the call in a try catch which adds info about _which_ declaration the error was thrown on 28 | */ 29 | export const getTypeDefinitionFromDeclarationWithHelpfulError = ({ 30 | declaration, 31 | }: { 32 | declaration: ResourceDeclaration | QueryDeclaration; 33 | }) => { 34 | try { 35 | return getTypeDefinitionFromDeclaration({ declaration }); 36 | } catch (error) { 37 | // log that we have failed 38 | const failureMessage = ` ${chalk.bold(chalk.red('x'))} ${chalk.red( 39 | chalk.bold('[PARSED]'), 40 | )} ${chalk.bold(declaration.path)}\n`; 41 | console.log(failureMessage); // tslint:disable-line no-console 42 | 43 | // and pass the error up 44 | throw new ErrorExtractingTypeDefinitionFromDeclaration({ 45 | declaration, 46 | error, 47 | }); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/flattenSqlByReferencingAndTokenizingSubqueries/extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings.test.ts: -------------------------------------------------------------------------------- 1 | import { extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings } from './extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings'; 2 | 3 | describe('extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings', () => { 4 | it('should not do anything if its just a string', () => { 5 | const sqlParts = ['select u.id from user u where u.id = :id']; 6 | const { referencedSqlParts, references } = 7 | extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings({ sqlParts }); 8 | expect(referencedSqlParts).toEqual(sqlParts); 9 | expect(references.length).toEqual(0); 10 | }); 11 | it('should extract reference if one of the sql parts is a subquery', () => { 12 | const sqlParts = [ 13 | ` 14 | SELECT 15 | s.id, 16 | s.uuid, 17 | s.name, 18 | `.trim(), 19 | ` 20 | ( 21 | SELECT GROUP_CONCAT(ice_cream_to_ingredient.ingredient_id ORDER BY ice_cream_to_ingredient.ingredient_id) 22 | FROM ice_cream_to_ingredient WHERE ice_cream_to_ingredient.ice_cream_id = s.id 23 | ) 24 | `.trim(), 25 | ` 26 | as ingredient_ids, 27 | s.created_at 28 | FROM ice_cream s 29 | ; 30 | `.trim(), 31 | ]; 32 | const { referencedSqlParts, references } = 33 | extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings({ sqlParts }); 34 | 35 | // should have got the reference 36 | expect(references.length).toEqual(1); 37 | expect(references[0]!.sql).toEqual(sqlParts[1]); 38 | 39 | // it should have replaced the sql in the referenced sql parts for the subquery 40 | expect(referencedSqlParts[1]).toMatch(/__SSQ:[\w-]+__/); 41 | 42 | // it should have not touched the remaining sql 43 | expect(referencedSqlParts[0]).toEqual(sqlParts[0]); 44 | expect(referencedSqlParts[2]).toEqual(sqlParts[2]); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/resource/view/defineTypescriptTypesForView.test.ts: -------------------------------------------------------------------------------- 1 | import { TypeDefinitionOfResourceTable } from '../../../../domain'; 2 | import { extractSqlFromFile } from '../../../common/extractSqlFromFile'; 3 | import { extractTypeDefinitionFromViewSql } from '../../../sqlToTypeDefinitions/resource/view/extractTypeDefinitionFromViewSql'; 4 | import { defineTypescriptTypesForView } from './defineTypescriptTypesForView'; 5 | 6 | describe('defineTypescriptTypesForView', () => { 7 | it('should generate an accurate looking interface for a view joining three tables and selecting from two', async () => { 8 | const definition = extractTypeDefinitionFromViewSql({ 9 | name: 'image', 10 | sql: await extractSqlFromFile({ 11 | filePath: `${__dirname}/../../../__test_assets__/views/view_suggestion_current.sql`, 12 | }), 13 | }); 14 | const code = defineTypescriptTypesForView({ 15 | definition, 16 | allDefinitions: [ 17 | new TypeDefinitionOfResourceTable({ name: 'suggestion', columns: [] }), 18 | new TypeDefinitionOfResourceTable({ 19 | name: 'suggestion_version', 20 | columns: [], 21 | }), 22 | ], 23 | }); 24 | expect(code).toMatchSnapshot(); 25 | }); 26 | it('should generate an accurate looking interface for another view joining three tables and selecting from two', async () => { 27 | const definition = extractTypeDefinitionFromViewSql({ 28 | name: 'image', 29 | sql: await extractSqlFromFile({ 30 | filePath: `${__dirname}/../../../__test_assets__/views/view_job_current.sql`, 31 | }), 32 | }); 33 | const code = defineTypescriptTypesForView({ 34 | definition, 35 | allDefinitions: [ 36 | new TypeDefinitionOfResourceTable({ name: 'job', columns: [] }), 37 | new TypeDefinitionOfResourceTable({ name: 'job_version', columns: [] }), 38 | ], 39 | }); 40 | expect(code).toMatchSnapshot(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/function/extractInputsFromFunctionSql/extractTypeDefinitionFromFunctionInputSql.test.ts: -------------------------------------------------------------------------------- 1 | import { DataType } from '../../../../../domain'; 2 | import { TypeDefinitionOfResourceColumn } from '../../../../../domain/objects/TypeDefinitionOfResourceColumn'; 3 | import { extractTypeDefinitionFromFunctionInputSql } from './extractTypeDefinitionFromFunctionInputSql'; 4 | 5 | describe('extractTypeDefinitionFromFunctionInputSql', () => { 6 | const examples = [ 7 | { 8 | sql: 'in_price DECIMAL(5,2)', 9 | def: new TypeDefinitionOfResourceColumn({ 10 | name: 'in_price', 11 | type: [DataType.NUMBER, DataType.NULL], 12 | }), 13 | }, 14 | { 15 | sql: 'in_created_at datetime(6)', 16 | def: new TypeDefinitionOfResourceColumn({ 17 | name: 'in_created_at', 18 | type: [DataType.DATE, DataType.NULL], 19 | }), 20 | }, 21 | { 22 | sql: 'in_credit VARCHAR(190)', 23 | def: new TypeDefinitionOfResourceColumn({ 24 | name: 'in_credit', 25 | type: [DataType.STRING, DataType.NULL], 26 | }), 27 | }, 28 | { 29 | sql: 'in_photo_ids bigint[]', 30 | def: new TypeDefinitionOfResourceColumn({ 31 | name: 'in_photo_ids', 32 | type: [DataType.NUMBER_ARRAY, DataType.NULL], 33 | }), 34 | }, 35 | { 36 | sql: 'in_verified boolean', 37 | def: new TypeDefinitionOfResourceColumn({ 38 | name: 'in_verified', 39 | type: [DataType.BOOLEAN, DataType.NULL], 40 | }), 41 | }, 42 | { 43 | sql: 'in_adhoc_data jsonb', 44 | def: new TypeDefinitionOfResourceColumn({ 45 | name: 'in_adhoc_data', 46 | type: [DataType.JSON, DataType.NULL], 47 | }), 48 | }, 49 | ]; 50 | examples.forEach((example) => { 51 | it(`should be able to determine types accurately for this example: "${example.sql}"`, () => { 52 | const def = extractTypeDefinitionFromFunctionInputSql({ 53 | sql: example.sql, 54 | }); 55 | expect(def).toEqual(example.def); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractSelectExpressionsFromQuerySql/extractTypeDefinitionReferenceFromSubqueryReferenceToken.ts: -------------------------------------------------------------------------------- 1 | import { TypeDefinitionReference } from '../../../../domain'; 2 | import { SqlSubqueryReference } from '../../../../domain/objects/SqlSubqueryReference'; 3 | import { getTokenForSqlSubqueryReference } from '../common/flattenSqlByReferencingAndTokenizingSubqueries/getTokenForSubqueryReference'; 4 | import { extractSelectExpressionsFromQuerySql } from './extractSelectExpressionsFromQuerySql'; 5 | 6 | export const extractTypeDefinitionReferenceFromSubqueryReferenceToken = ({ 7 | subqueryReferenceToken, 8 | subqueries, 9 | }: { 10 | subqueryReferenceToken: string; 11 | subqueries: SqlSubqueryReference[]; 12 | }): TypeDefinitionReference => { 13 | // 1. find the subquery reference object 14 | const subquery = subqueries.find( 15 | (subquery) => 16 | getTokenForSqlSubqueryReference({ reference: subquery }) === 17 | subqueryReferenceToken, 18 | ); 19 | if (!subquery) { 20 | throw new Error( 21 | 'subquery reference token found in select expression - but subquery reference definition not present. unexpected', 22 | ); // fail fast 23 | } 24 | 25 | // 2. get select expressions from subquery sql 26 | const cleanedSubquerySql = subquery.sql.replace(/^\(/, '').replace(/\)$/, ''); // if it has leading or closing paren, strip em 27 | const selectExpressions = extractSelectExpressionsFromQuerySql({ 28 | sql: cleanedSubquerySql, 29 | inASubquery: true, 30 | }); // NOTE: this makes this recursive 31 | 32 | // 3. check that the subquery returns exactly one select expression, otherwise this is a syntax error and not valid sql 33 | if (selectExpressions.length < 1) { 34 | throw new Error( 35 | 'subquery in select expression must return atleast one value.', 36 | ); 37 | } 38 | if (selectExpressions.length > 1) { 39 | throw new Error( 40 | 'subquery in select expression may not return more than one value. this is invalid sql', 41 | ); 42 | } 43 | 44 | // 4. the reference type of this expression is the single reference type of that subquery 45 | return selectExpressions[0]!.typeReference; 46 | }; 47 | -------------------------------------------------------------------------------- /src/logic/commands/generate/generate.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import { readConfig } from '../../config/getConfig/readConfig'; 4 | import { defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions } from './defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions/defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions'; 5 | import { defineTypescriptTypesFileCodeFromTypeDefinitions } from './defineTypescriptTypesFileCodeFromTypeDefinition/defineTypescriptTypesFileCodeFromTypeDefinition'; 6 | import { extractTypeDefinitionsFromDeclarations } from './extractTypeDefinitionsFromDeclarations/extractTypeDefinitionsFromDeclarations'; 7 | import { saveCode } from './saveCode'; 8 | 9 | export const generate = async ({ configPath }: { configPath: string }) => { 10 | // read the declarations from config 11 | const config = await readConfig({ filePath: configPath }); 12 | 13 | // get type definitions for each resource and query 14 | console.log(chalk.bold('Parsing sql and extracting type definitions...\n')); // tslint:disable-line no-console 15 | const definitions = extractTypeDefinitionsFromDeclarations({ 16 | language: config.language, 17 | declarations: config.declarations, 18 | }); 19 | 20 | // begin generating the output code files 21 | console.log(chalk.bold('Generating code...\n')); // tslint:disable-line no-console 22 | 23 | // output the type definitions code 24 | const typescriptTypesFileCode = 25 | defineTypescriptTypesFileCodeFromTypeDefinitions({ 26 | definitions, 27 | }); 28 | await saveCode({ 29 | rootDir: config.rootDir, 30 | filePath: config.generates.types, 31 | code: typescriptTypesFileCode, 32 | }); 33 | 34 | // output the query functions (if requested) 35 | if (config.generates.queryFunctions) { 36 | const typescriptQueryFunctionsFileCode = 37 | defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions({ 38 | definitions, 39 | language: config.language, 40 | generatedOutputPaths: config.generates, 41 | }); 42 | await saveCode({ 43 | rootDir: config.rootDir, 44 | filePath: config.generates.queryFunctions, 45 | code: typescriptQueryFunctionsFileCode, 46 | }); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractInputVariablesFromQuerySql/extractInputVariableTokensFromQuerySql.test.ts: -------------------------------------------------------------------------------- 1 | import { extractInputVariableTokensFromQuerySql } from './extractInputVariableTokensFromQuerySql'; 2 | 3 | describe('extractInputVariableTokensFromQuerySql', () => { 4 | it('should find tokens inside where clause', () => { 5 | const sql = ` 6 | select 7 | i.url 8 | from image i 9 | where id = :id 10 | `.trim(); 11 | const tokens = extractInputVariableTokensFromQuerySql({ sql }); 12 | expect(tokens).toEqual([':id']); 13 | }); 14 | it('should not confuse a hardcoded time with tokens', () => { 15 | const sql = ` 16 | select 17 | i.url 18 | from image i 19 | where s.created_at > '2020-01-01 05:55:55'; 20 | `; 21 | const tokens = extractInputVariableTokensFromQuerySql({ sql }); 22 | expect(tokens).toEqual([]); 23 | }); 24 | it('should not confuse a postgres type casting with tokens - normal', () => { 25 | const sql = ` 26 | SELECT 1::boolean; 27 | `; 28 | const tokens = extractInputVariableTokensFromQuerySql({ sql }); 29 | expect(tokens).toEqual([]); 30 | }); 31 | it('should not confuse a postgres type casting with tokens - array', () => { 32 | const sql = ` 33 | SELECT coalesce(array_agg(train_to_locomotive.locomotive_id ORDER BY train_to_locomotive.array_order_index), array[]::bigint[]) as array_agg 34 | FROM train_to_locomotive WHERE train_to_locomotive.train_id = s.id 35 | `; 36 | const tokens = extractInputVariableTokensFromQuerySql({ sql }); 37 | expect(tokens).toEqual([]); 38 | }); 39 | it('should find tokens inside of a function on one line', () => { 40 | const sql = ` 41 | SELECT get_cool_stuff_from_db(:name, :plane); 42 | `.trim(); 43 | const tokens = extractInputVariableTokensFromQuerySql({ sql }); 44 | expect(tokens).toEqual([':name', ':plane']); 45 | }); 46 | it('should find tokens inside of a function on multiple lines', () => { 47 | const sql = ` 48 | SELECT upsert_plane( 49 | :model, 50 | :ownerId, 51 | :pin, 52 | ); 53 | `.trim(); 54 | const tokens = extractInputVariableTokensFromQuerySql({ sql }); 55 | expect(tokens).toEqual([':model', ':ownerId', ':pin']); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/utils/environment.ts: -------------------------------------------------------------------------------- 1 | import { UnexpectedCodePathError } from '@ehmpathy/error-fns'; 2 | import { createIsOfEnum } from 'type-fns'; 3 | 4 | export enum Stage { 5 | PRODUCTION = 'prod', 6 | DEVELOPMENT = 'dev', 7 | TEST = 'test', 8 | } 9 | export const isOfStage = createIsOfEnum(Stage); 10 | 11 | /** 12 | * verify that the server is on UTC timezone 13 | * 14 | * why? 15 | * - non UTC timezone usage causes problems and takes a while to track down 16 | * - by failing fast if the server our code runs in is not in UTC, we avoid these issues 17 | * => 18 | * - create a pit of success 19 | */ 20 | const TIMEZONE = process.env.TZ; 21 | if (TIMEZONE !== 'UTC') 22 | throw new UnexpectedCodePathError( 23 | 'env.TZ is not set to UTC. this can cause issues. please set the env var', 24 | { found: TIMEZONE, desire: 'UTC' }, 25 | ); 26 | 27 | /** 28 | * this allows us to infer what the stage should be in environments that do not have STAGE specified 29 | * - e.g., when running locally 30 | * - e.g., when running tests 31 | */ 32 | const inferStageFromNodeEnv = () => { 33 | const nodeEnv = process.env.NODE_ENV; // default to test if not defined 34 | if (!nodeEnv) throw new Error('process.env.NODE_ENV must be defined'); 35 | if (nodeEnv === 'production') return Stage.PRODUCTION; 36 | if (nodeEnv === 'development') return Stage.DEVELOPMENT; 37 | if (nodeEnv === 'test') return Stage.TEST; 38 | throw new Error(`unexpected nodeEnv '${nodeEnv}'`); 39 | }; 40 | 41 | /** 42 | * a method that exposes relevant environmental variables in a standard way 43 | */ 44 | const getEnvironment = () => { 45 | const stage = process.env.STAGE ?? inferStageFromNodeEnv(); // figure it out from NODE_ENV if not explicitly defined 46 | if (!stage) throw new Error('process.env.STAGE must be defined'); 47 | if (!isOfStage(stage)) throw new Error(`invalid stage defined '${stage}'`); 48 | return { stage }; 49 | }; 50 | 51 | // export stage immediately, since it does not change 52 | export const { stage } = getEnvironment(); 53 | 54 | // export service client stage 55 | export const serviceClientStage = 56 | stage === Stage.PRODUCTION ? Stage.PRODUCTION : Stage.DEVELOPMENT; // i.e., if its prod, hit prod. otherwise, dev 57 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/table/extractTypeDefinitionFromColumnSql.test.ts: -------------------------------------------------------------------------------- 1 | import { DataType } from '../../../../domain'; 2 | import { TypeDefinitionOfResourceColumn } from '../../../../domain/objects/TypeDefinitionOfResourceColumn'; 3 | import { extractTypeDefinitionFromColumnSql } from './extractTypeDefinitionFromColumnSql'; 4 | 5 | describe('extractTypeDefinitionFromColumnSql', () => { 6 | const examples = [ 7 | { 8 | sql: '`id` bigint(20) NOT NULL AUTO_INCREMENT', 9 | def: new TypeDefinitionOfResourceColumn({ 10 | name: 'id', 11 | type: [DataType.NUMBER], 12 | }), 13 | }, 14 | { 15 | sql: 'price DECIMAL(5,2) DEFAULT NULL', 16 | def: new TypeDefinitionOfResourceColumn({ 17 | name: 'price', 18 | type: [DataType.NUMBER, DataType.NULL], 19 | }), 20 | }, 21 | { 22 | sql: 'uuid char(36) COLLATE utf8mb4_bin NOT NULL', 23 | def: new TypeDefinitionOfResourceColumn({ 24 | name: 'uuid', 25 | type: [DataType.STRING], 26 | }), 27 | }, 28 | { 29 | sql: '`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)', 30 | def: new TypeDefinitionOfResourceColumn({ 31 | name: 'created_at', 32 | type: [DataType.DATE], 33 | }), 34 | }, 35 | { 36 | sql: 'credit VARCHAR(190) COLLATE utf8mb4_bin', 37 | def: new TypeDefinitionOfResourceColumn({ 38 | name: 'credit', 39 | type: [DataType.STRING, DataType.NULL], 40 | }), 41 | }, 42 | { 43 | sql: 'verified BOOLEAN', 44 | def: new TypeDefinitionOfResourceColumn({ 45 | name: 'verified', 46 | type: [DataType.BOOLEAN, DataType.NULL], 47 | }), 48 | }, 49 | { 50 | sql: 'adhoc_data JSONB NOT NULL', 51 | def: new TypeDefinitionOfResourceColumn({ 52 | name: 'adhoc_data', 53 | type: [DataType.JSON], 54 | }), 55 | }, 56 | ]; 57 | examples.forEach((example) => { 58 | it(`should be able to determine types accurately for this example: "${example.sql}"`, () => { 59 | const def = extractTypeDefinitionFromColumnSql({ sql: example.sql }); 60 | expect(def).toEqual(example.def); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/logic/typeDefinitionsToCode/query/defineTypescriptQueryFunctionForQuery.ts: -------------------------------------------------------------------------------- 1 | import { camelCase } from 'change-case'; 2 | 3 | import { castQueryNameToTypescriptTypeName } from '../common/castQueryNameToTypescriptTypeName'; 4 | 5 | export interface QueryFunctionDefinition { 6 | code: string; 7 | imports: { 8 | queryNameAlias: string; 9 | generatedTypes: string[]; 10 | }; 11 | } 12 | 13 | /** 14 | * given: 15 | * - typescript type definition of input and output 16 | * 17 | * produce: 18 | * - typescript function that gets the inputs and returns promise of output 19 | * - typescript function that calls database in best practice way 20 | * - uses yesql to pass inputs into parameterized statement 21 | * - parses result and extracts output 22 | * - logs inputs and output stats 23 | */ 24 | export const defineTypescriptQueryFunctionForQuery = ({ 25 | name, 26 | }: { 27 | name: string; 28 | }): QueryFunctionDefinition => { 29 | const typescriptTypeName = castQueryNameToTypescriptTypeName({ name }); 30 | 31 | // define the query name alias, import { query as ... } under 32 | const queryNameAlias = `${camelCase(typescriptTypeName)}Sql`; // i.e., sqlQueryUpsertUserSql 33 | 34 | // define the generated types to import 35 | const outputTypeName = `${typescriptTypeName}Output`; 36 | const inputTypeName = `${typescriptTypeName}Input`; 37 | const generatedTypesToImport = [inputTypeName, outputTypeName]; 38 | 39 | // define the function body 40 | const typescriptQueryFunctionName = camelCase(typescriptTypeName); // i.e., same as typename, except camel case 41 | const queryFunctionDefinition = ` 42 | export const ${typescriptQueryFunctionName} = async ({ 43 | dbExecute, 44 | logDebug, 45 | input, 46 | }: { 47 | dbExecute: DatabaseExecuteCommand; 48 | logDebug: LogMethod; 49 | input: ${inputTypeName}; 50 | }): Promise<${outputTypeName}[]> => 51 | executeQueryWithBestPractices({ 52 | dbExecute, 53 | logDebug, 54 | name: '${typescriptQueryFunctionName}', 55 | sql: ${queryNameAlias}, 56 | input, 57 | }); 58 | `.trim(); 59 | return { 60 | imports: { 61 | queryNameAlias, 62 | generatedTypes: generatedTypesToImport, 63 | }, 64 | code: queryFunctionDefinition, 65 | }; 66 | }; 67 | -------------------------------------------------------------------------------- /src/logic/commands/generate/defineTypescriptTypesFileCodeFromTypeDefinition/defineTypescriptTypesFileCodeFromTypeDefinition.ts: -------------------------------------------------------------------------------- 1 | import { TypeDefinition } from '../../../../domain'; 2 | import { getTypescriptTypesFromTypeDefinition } from '../../../typeDefinitionsToCode/getTypescriptTypesFromTypeDefinition'; 3 | 4 | const typeDefinitionSortOrder = [ 5 | 'TypeDefinitionOfResourceTable', 6 | 'TypeDefinitionOfResourceFunction', 7 | 'TypeDefinitionOfResourceView', 8 | 'TypeDefinitionOfQuery', 9 | ]; 10 | const sortTypeDefinition = (a: TypeDefinition, b: TypeDefinition) => { 11 | // make return values easier to understand 12 | const A_FIRST = -1; 13 | const B_FIRST = 1; 14 | 15 | // try to sort on definition type 16 | const definitionTypeIndexA = typeDefinitionSortOrder.indexOf( 17 | a.constructor.name, 18 | ); 19 | const definitionTypeIndexB = typeDefinitionSortOrder.indexOf( 20 | b.constructor.name, 21 | ); 22 | if (definitionTypeIndexA < definitionTypeIndexB) return A_FIRST; 23 | if (definitionTypeIndexA > definitionTypeIndexB) return B_FIRST; 24 | 25 | // sort on name if of same type 26 | if (a.name < b.name) return A_FIRST; 27 | if (a.name > b.name) return B_FIRST; 28 | return 0; 29 | }; 30 | 31 | export const defineTypescriptTypesFileCodeFromTypeDefinitions = ({ 32 | definitions, 33 | }: { 34 | definitions: TypeDefinition[]; 35 | }) => { 36 | // sort typedefs for deterministic and priority ranked output order 37 | const sortedDefinitions = [...definitions].sort(sortTypeDefinition); 38 | 39 | // define codes 40 | const typescriptTypesCodes = sortedDefinitions.map((definition) => { 41 | const typescriptTypesCodeForDef = getTypescriptTypesFromTypeDefinition({ 42 | definition, 43 | allDefinitions: definitions, 44 | }); 45 | const definitionTypeCommonName = definition.constructor.name 46 | .replace('TypeDefinitionOfResource', '') 47 | .replace('TypeDefinitionOf', '') 48 | .toLowerCase(); 49 | return ` 50 | // types for ${definitionTypeCommonName} '${definition.name}' 51 | ${typescriptTypesCodeForDef} 52 | `.trim(); 53 | }); 54 | 55 | // merge the codes 56 | const typescriptTypesCode = ` 57 | ${typescriptTypesCodes.join('\n\n')} 58 | `.trim(); 59 | 60 | // return it, w/ a new line at end 61 | return `${typescriptTypesCode}\n`; 62 | }; 63 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/common/flattenSqlByReferencingAndTokenizingSubqueries/flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive.ts: -------------------------------------------------------------------------------- 1 | import { SqlSubqueryReference } from '../../../../../domain/objects/SqlSubqueryReference'; 2 | import { NestedStringArray } from './breakSqlIntoNestedSqlArraysAtParentheses'; 3 | import { extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings } from './extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings'; 4 | 5 | export const flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive = 6 | ({ 7 | sqlOrNestedSqlArray, 8 | }: { 9 | sqlOrNestedSqlArray: NestedStringArray | string; 10 | }): { references: SqlSubqueryReference[]; flattenedSql: string } => { 11 | // 0. if this is a string, then no references - its already flat 12 | if (!Array.isArray(sqlOrNestedSqlArray)) { 13 | return { 14 | references: [], 15 | flattenedSql: sqlOrNestedSqlArray, 16 | }; 17 | } 18 | 19 | // 1. since its not a string, its a nested sql array; update name of const for readability 20 | const nestedSqlArray = sqlOrNestedSqlArray; 21 | 22 | // 2. initialize the references object, so we can track references over nested arrays 23 | const references: SqlSubqueryReference[] = []; 24 | 25 | // 3. for each element in this array, flatten it. track the nested references 26 | const flattenedSqlParts = nestedSqlArray.map((thisSqlOrNestedSqlArray) => { 27 | const { references: nestedReferences, flattenedSql: nestedFlattenedSql } = 28 | flattenNestedArraySqlByReferencingAndTokenizingSubqueriesRecursive({ 29 | sqlOrNestedSqlArray: thisSqlOrNestedSqlArray, 30 | }); 31 | references.push(...nestedReferences); 32 | return nestedFlattenedSql; 33 | }); 34 | 35 | // 4. extract references from this array of flattened strings 36 | const { references: referencesFromFlattenedSqlParts, referencedSqlParts } = 37 | extractAndTokenizeSubqueryReferencesInArrayOfSqlStrings({ 38 | sqlParts: flattenedSqlParts, 39 | }); 40 | references.push(...referencesFromFlattenedSqlParts); // append the newly found references 41 | 42 | // 5. join all of the flattened, referenced strings 43 | const flattenedSql = referencedSqlParts.join(''); 44 | 45 | // 6. return the references and sql 46 | return { 47 | references, 48 | flattenedSql, 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractSelectExpressionsFromQuerySql/extractTypeDefinitionReferenceFromSelectExpressionSql.ts: -------------------------------------------------------------------------------- 1 | import { SqlSubqueryReference } from '../../../../domain/objects/SqlSubqueryReference'; 2 | import { TypeDefinitionReference } from '../../../../domain/objects/TypeDefinitionReference'; 3 | import { throwErrorIfTableReferencePathImpliesTable } from '../common/throwErrorIfTableReferencePathImpliesTable'; 4 | import { extractTypeDefinitionReferenceFromSubqueryReferenceToken } from './extractTypeDefinitionReferenceFromSubqueryReferenceToken'; 5 | 6 | export const extractTypeDefinitionReferenceFromSelectExpression = ({ 7 | sql, 8 | subqueries, 9 | }: { 10 | sql: string; 11 | subqueries: SqlSubqueryReference[]; 12 | }) => { 13 | // 0. try to extract the reference from a subquery, if query references subquery 14 | const [ 15 | _, // tslint:disable-line no-unused 16 | subqueryReferenceToken, 17 | ] = new RegExp(/^(__SSQ:[\w-]+__)(?:\s+(?:[\w\s]*))?$/).exec(sql) ?? []; 18 | if (subqueryReferenceToken) { 19 | return extractTypeDefinitionReferenceFromSubqueryReferenceToken({ 20 | subqueryReferenceToken, 21 | subqueries, 22 | }); 23 | } 24 | 25 | // 1. try to extract a tableReferencePath, if query references table 26 | const [ 27 | __, // tslint:disable-line no-unused 28 | tableReferencePath, 29 | ] = new RegExp(/^(\w+\.?\w*)(?:\s+(?:[\w\s]*))?$/).exec(sql) ?? []; 30 | if (tableReferencePath) { 31 | throwErrorIfTableReferencePathImpliesTable({ 32 | referencePath: tableReferencePath, 33 | }); // check that table reference is explicitly defined, since its best practice 34 | return new TypeDefinitionReference({ 35 | tableReferencePath, 36 | functionReferencePath: null, 37 | }); 38 | } 39 | 40 | // 2. try to extract a functionReferencePath, if query references function 41 | const [ 42 | ___, // tslint:disable-line no-unused 43 | referencedFunctionName, 44 | ] = new RegExp(/(\w+)(?:\([\w\s\.:,\"'\|]+\))/).exec(sql) ?? []; 45 | if (referencedFunctionName) { 46 | return new TypeDefinitionReference({ 47 | tableReferencePath: null, 48 | functionReferencePath: `${referencedFunctionName.toLowerCase()}.output`, 49 | }); 50 | } 51 | 52 | // 3. if couldn't determine type reference with either of the above, throw an error 53 | throw new Error(`could not extract type definition reference from '${sql}'`); 54 | }; 55 | -------------------------------------------------------------------------------- /src/logic/commands/generate/defineTypescriptQueryFunctionsFileCodeFromTypeDefinitions/defineTypescriptImportQuerySqlCodeForQueryFunctions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TypeDefinitionOfQuery, 3 | GeneratedOutputPaths, 4 | } from '../../../../domain'; 5 | import { defineTypescriptQueryFunctionForQuery } from '../../../typeDefinitionsToCode/query/defineTypescriptQueryFunctionForQuery'; 6 | import { QueryFunctionsOutputPathNotDefinedButRequiredError } from './QueryFunctionsOutputPathNotDefinedError'; 7 | import { getRelativePathFromFileToFile } from './utils/getRelativePathFromFileToFile'; 8 | 9 | const defineTypescriptImportQuerySqlCodeForAQueryFunction = ({ 10 | definition, 11 | generatedOutputPaths, 12 | }: { 13 | definition: TypeDefinitionOfQuery; 14 | generatedOutputPaths: GeneratedOutputPaths; // to find the relative path from here to the sql declaration file path 15 | }) => { 16 | // check that the query functions output path was defined; if it was not, this code path should not have been called so fail fast 17 | if (!generatedOutputPaths.queryFunctions) 18 | throw new QueryFunctionsOutputPathNotDefinedButRequiredError(); 19 | 20 | // determine the path from the generated code to the query sql export 21 | const relativePathToExport = getRelativePathFromFileToFile({ 22 | fromFile: generatedOutputPaths.queryFunctions, 23 | toFile: definition.path, 24 | }); 25 | 26 | // determine the query name alias to use for this function 27 | const importAlias = defineTypescriptQueryFunctionForQuery({ 28 | name: definition.name, 29 | }).imports.queryNameAlias; 30 | 31 | // return the import statement 32 | return `import { sql as ${importAlias} } from '${relativePathToExport}';`; 33 | }; 34 | 35 | export const defineTypescriptImportQuerySqlCodeForQueryFunctions = ({ 36 | queryDefinitions, 37 | generatedOutputPaths, 38 | }: { 39 | queryDefinitions: TypeDefinitionOfQuery[]; 40 | generatedOutputPaths: GeneratedOutputPaths; 41 | }) => { 42 | // define all of the imports needed 43 | const importStatements = queryDefinitions 44 | .sort((a, b) => (a.path < b.path ? -1 : 1)) 45 | .map((definition) => 46 | defineTypescriptImportQuerySqlCodeForAQueryFunction({ 47 | definition, 48 | generatedOutputPaths, 49 | }), 50 | ); 51 | 52 | // define the import code 53 | const importCode = ` 54 | ${importStatements.join('\n')} 55 | `.trim(); 56 | 57 | // return it 58 | return importCode; 59 | }; 60 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/query/extractSelectExpressionsFromQuerySql/extractTypeDefinitionFromSelectExpressionSql.ts: -------------------------------------------------------------------------------- 1 | import { TypeDefinitionOfQuerySelectExpression } from '../../../../domain'; 2 | import { SqlSubqueryReference } from '../../../../domain/objects/SqlSubqueryReference'; 3 | import { extractTypeDefinitionReferenceFromSelectExpression } from './extractTypeDefinitionReferenceFromSelectExpressionSql'; 4 | 5 | export const extractTypeDefinitionFromSelectExpressionSql = ({ 6 | sql, 7 | subqueries, 8 | inASubquery, 9 | }: { 10 | sql: string; 11 | subqueries: SqlSubqueryReference[]; 12 | inASubquery: boolean; // used to indicate whether we are currently running on a subquery or not; being in subquery gives us some implicit info 13 | }) => { 14 | // grab the type reference 15 | const typeReference = extractTypeDefinitionReferenceFromSelectExpression({ 16 | sql, 17 | subqueries, 18 | }); 19 | 20 | // grab the alias, if any 21 | const [__, specifiedAlias] = 22 | new RegExp(/(?:[\w\.,:\(\)\s\|\']+)(?:\s+)(?:as|AS)(?:\s+)(\w+)/g).exec( 23 | sql, 24 | ) ?? []; // tslint:disable-line no-unused 25 | 26 | // define the alias, considering whether alias was specified 27 | if ( 28 | !specifiedAlias && 29 | !!typeReference.functionReferencePath && 30 | !inASubquery 31 | ) { 32 | // throw error if fn reference and alias not defined, as its best practice to explicitly define it - and we're not going to "infer" it for the user 33 | throw new Error( 34 | ` 35 | select expressions that reference a function must have an alias defined, per best practice. 36 | - e.g., \`select concat(n.first, n.last)\` => \`select concat(n.first, n.last) as full_name\` 37 | 38 | \`${typeReference.functionReferencePath}\` does not meet this criteria. 39 | `.trim(), 40 | ); 41 | } 42 | const alias = (() => { 43 | if (specifiedAlias) return specifiedAlias; 44 | if (inASubquery) return '__subquery_placeholder_name__'; // we wont use this alias anywhere, since we're in a subquery and subquery just returns one value; therefore, we can give it a fake name as a placeholder 45 | if (typeReference.tableReferencePath) 46 | return typeReference.tableReferencePath.split('.').slice(-1)[0]!; 47 | throw new Error('could not define alias for sql expression; unexpected'); 48 | })(); 49 | 50 | // return the full definition 51 | return new TypeDefinitionOfQuerySelectExpression({ 52 | alias, 53 | typeReference, 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/table/extractTypeDefinitionFromTableSql.test.ts: -------------------------------------------------------------------------------- 1 | import { extractSqlFromFile } from '../../../common/extractSqlFromFile'; 2 | import { extractTypeDefinitionFromTableSql } from './extractTypeDefinitionFromTableSql'; 3 | 4 | describe('extractTypeDefinitionFromTableSql', () => { 5 | describe('mysql', () => { 6 | it('should be able to extract types in this example', async () => { 7 | const exampleSql = await extractSqlFromFile({ 8 | filePath: `${__dirname}/../../../__test_assets__/tables/image.mysql.sql`, 9 | }); 10 | const typeDef = extractTypeDefinitionFromTableSql({ 11 | name: 'image', 12 | sql: exampleSql, 13 | }); 14 | expect(typeDef).toMatchSnapshot(); 15 | }); 16 | it('should be able to extract types in this other example', async () => { 17 | const exampleSql = await extractSqlFromFile({ 18 | filePath: `${__dirname}/../../../__test_assets__/tables/suggestion_version.mysql.sql`, 19 | }); 20 | const typeDef = extractTypeDefinitionFromTableSql({ 21 | name: 'suggestion_version', 22 | sql: exampleSql, 23 | }); 24 | expect(typeDef).toMatchSnapshot(); 25 | }); 26 | }); 27 | describe('postgres', () => { 28 | it('should be able to extract types in this example', async () => { 29 | const exampleSql = await extractSqlFromFile({ 30 | filePath: `${__dirname}/../../../__test_assets__/tables/photo.postgres.sql`, 31 | }); 32 | const typeDef = extractTypeDefinitionFromTableSql({ 33 | name: 'photo', 34 | sql: exampleSql, 35 | }); 36 | expect(typeDef).toMatchSnapshot(); 37 | }); 38 | it('should be able to extract types in this other example', async () => { 39 | const exampleSql = await extractSqlFromFile({ 40 | filePath: `${__dirname}/../../../__test_assets__/tables/job_version.postgres.sql`, 41 | }); 42 | const typeDef = extractTypeDefinitionFromTableSql({ 43 | name: 'job_version', 44 | sql: exampleSql, 45 | }); 46 | expect(typeDef).toMatchSnapshot(); 47 | }); 48 | it('should be able to extract types in this other example, having a check constraint with nested parens', async () => { 49 | const exampleSql = await extractSqlFromFile({ 50 | filePath: `${__dirname}/../../../__test_assets__/tables/job.postgres.sql`, 51 | }); 52 | const typeDef = extractTypeDefinitionFromTableSql({ 53 | name: 'job', 54 | sql: exampleSql, 55 | }); 56 | expect(typeDef).toMatchSnapshot(); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/table/extractTypeDefinitionFromTableSql.ts: -------------------------------------------------------------------------------- 1 | import strip from 'sql-strip-comments'; 2 | 3 | import { TypeDefinitionOfResourceTable } from '../../../../domain'; 4 | import { castCommasInParensToPipesForTokenSafety } from '../common/castCommasInParensToPipesForTokenSafety'; 5 | import { extractTypeDefinitionFromColumnSql } from './extractTypeDefinitionFromColumnSql'; 6 | 7 | /* 8 | note: 9 | - for table and view, just a map of { property: type } 10 | - for function and procedure, input type, output type 11 | */ 12 | 13 | const KEY_OR_CONSTRAINT_LINE_REGEX = /[KEY|CONSTRAINT].*\s\(/gi; 14 | 15 | export const extractTypeDefinitionFromTableSql = ({ 16 | name, 17 | sql, 18 | }: { 19 | name: string; 20 | sql: string; 21 | }) => { 22 | // 0. strip comments 23 | const strippedSql: string = strip(sql); 24 | 25 | // 1. drop everything after the first `;` - the table ddl does not have `;` inside. (this will make sure we exclude lines like "create index ..." and etc) 26 | const sqlBeforeFirstSemiColon = strippedSql.split(';')[0]!; 27 | // strippedSql.replace(/CREATE INDEX [\w\d_]+ ON [\w\d_]+ USING [\w\d_]+ \([\w\d_,]+\);/gi, ''); 28 | 29 | // 2. grab the insides of the "create" (i.e., 'CREATE TABLE ... (__INSIDES__) ...' => '__INSIDES__') 30 | const innerSqlAndAfter = sqlBeforeFirstSemiColon 31 | .split('(') 32 | .slice(1) 33 | .join('('); // drop the part before the first '(' 34 | const innerSql = innerSqlAndAfter.split(')').slice(0, -1).join(')'); // drop the part after the last ')' 35 | 36 | // 3. cast commas inside of parens into pipes, so that we treat them as unique tokens when splitting "property lines" by comma 37 | const parenTokenizedInnerSql = castCommasInParensToPipesForTokenSafety({ 38 | sql: innerSql, 39 | }); 40 | 41 | // 4. grab definition lines, by splitting out properties by commas 42 | const lines = parenTokenizedInnerSql.split(','); 43 | 44 | // 5. strip out the lines that are defining keys or constraints 45 | const columnDefinitions = lines 46 | .filter((line) => { 47 | return !new RegExp(KEY_OR_CONSTRAINT_LINE_REGEX, 'gim').test(line); // note, we reinstantiate so as to not share regex object (i.e., state) between calls 48 | }) 49 | .map((line) => line.trim()); 50 | 51 | // 6. get column definition from each property 52 | const columns = columnDefinitions.map((line) => 53 | extractTypeDefinitionFromColumnSql({ sql: line }), 54 | ); 55 | 56 | // 7. return the table definition 57 | return new TypeDefinitionOfResourceTable({ 58 | name, 59 | columns, 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/common/extractDataTypeFromColumnOrArgumentDefinitionSql.test.ts: -------------------------------------------------------------------------------- 1 | import { DataType } from '../../../../domain'; 2 | import { extractDataTypeFromColumnOrArgumentDefinitionSql } from './extractDataTypeFromColumnOrArgumentDefinitionSql'; 3 | 4 | describe('extractDataTypeFromColumnOrArgumentDefinitionSql', () => { 5 | const examples = [ 6 | // mysql 7 | { 8 | sql: '`id` bigint(20) NOT NULL AUTO_INCREMENT', 9 | type: DataType.NUMBER, 10 | }, 11 | { 12 | sql: 'price DECIMAL(5,2) DEFAULT NULL', 13 | type: DataType.NUMBER, 14 | }, 15 | { 16 | sql: 'uuid char(36) COLLATE utf8mb4_bin NOT NULL', 17 | type: DataType.STRING, 18 | }, 19 | { 20 | sql: '`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)', 21 | type: DataType.DATE, 22 | }, 23 | { 24 | sql: 'credit VARCHAR(190) COLLATE utf8mb4_bin', 25 | type: DataType.STRING, 26 | }, 27 | { 28 | sql: 'in_url varchar(190)', 29 | type: DataType.STRING, 30 | }, 31 | { 32 | sql: '`ingredient_ids_hash` binary(32) NOT NULL', 33 | type: DataType.BUFFER, 34 | }, 35 | // postgres 36 | { 37 | sql: 'id bigserial NOT NULL', 38 | type: DataType.NUMBER, 39 | }, 40 | { 41 | sql: 'price numeric(5, 2) DEFAULT NULL', 42 | type: DataType.NUMBER, 43 | }, 44 | { 45 | sql: 'uuid uuid NOT NULL', 46 | type: DataType.STRING, 47 | }, 48 | { 49 | sql: 'description text NULL', 50 | type: DataType.STRING, 51 | }, 52 | { 53 | sql: 'nicknames varchar[] NOT NULL', 54 | type: DataType.STRING_ARRAY, 55 | }, 56 | { 57 | sql: 'created_at timestamptz NOT NULL DEFAULT now()', 58 | type: DataType.DATE, 59 | }, 60 | { 61 | sql: 'photo_ids_hash bytea NULL', 62 | type: DataType.BUFFER, 63 | }, 64 | { 65 | sql: 'in_photo_ids bigint[]', 66 | type: DataType.NUMBER_ARRAY, 67 | }, 68 | { 69 | sql: 'verified boolean', 70 | type: DataType.BOOLEAN, 71 | }, 72 | { 73 | sql: 'in_adhoc_data jsonb', 74 | type: DataType.JSON, 75 | }, 76 | { 77 | sql: 'in_adhoc_data JSON', 78 | type: DataType.JSON, 79 | }, 80 | ]; 81 | examples.forEach((example) => { 82 | it(`should be able to determine type accurately for this example: "${example.sql}"`, () => { 83 | const type = extractDataTypeFromColumnOrArgumentDefinitionSql({ 84 | sql: example.sql, 85 | }); 86 | expect(type).toEqual(example.type); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/function/extractOutputFromFunctionSql/extractOutputFromFunctionSql.test.ts: -------------------------------------------------------------------------------- 1 | import { DataType, TypeDefinitionOfResourceTable } from '../../../../../domain'; 2 | import { extractSqlFromFile } from '../../../../common/extractSqlFromFile'; 3 | import { extractOutputFromFunctionSql } from './extractOutputFromFunctionSql'; 4 | 5 | describe('extractOutputsFromFunctionSql', () => { 6 | describe('mysql', () => { 7 | it('should extract the output accurately in this example', async () => { 8 | const type = extractOutputFromFunctionSql({ 9 | sql: await extractSqlFromFile({ 10 | filePath: `${__dirname}/../../../../__test_assets__/functions/upsert_image.mysql.sql`, 11 | }), 12 | }); 13 | expect(type).toEqual([DataType.NUMBER]); 14 | }); 15 | it('should extract the output accurately in this other example', async () => { 16 | const type = extractOutputFromFunctionSql({ 17 | sql: await extractSqlFromFile({ 18 | filePath: `${__dirname}/../../../../__test_assets__/functions/hash_string.mysql.sql`, 19 | }), 20 | }); 21 | expect(type).toEqual([DataType.STRING]); 22 | }); 23 | }); 24 | describe('postgres', () => { 25 | it('should extract the output accurately in this example', async () => { 26 | const type = extractOutputFromFunctionSql({ 27 | sql: await extractSqlFromFile({ 28 | filePath: `${__dirname}/../../../../__test_assets__/functions/upsert_photo.postgres.sql`, 29 | }), 30 | }); 31 | expect(type).toEqual([DataType.NUMBER]); 32 | }); 33 | it('should extract output types accurately when output is a table, instead of just a DataType[]', async () => { 34 | const type = extractOutputFromFunctionSql({ 35 | sql: await extractSqlFromFile({ 36 | filePath: `${__dirname}/../../../../__test_assets__/functions/upsert_jerb.postgres.sql`, 37 | }), 38 | }); 39 | expect(type).toEqual( 40 | new TypeDefinitionOfResourceTable({ 41 | name: 'function.output', 42 | columns: [ 43 | { 44 | name: 'id', 45 | type: [DataType.NUMBER], 46 | }, 47 | { 48 | name: 'uuid', 49 | type: [DataType.STRING], 50 | }, 51 | { 52 | name: 'created_at', 53 | type: [DataType.DATE], 54 | }, 55 | { 56 | name: 'effective_at', 57 | type: [DataType.DATE], 58 | }, 59 | ], 60 | }), 61 | ); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/logic/sqlToTypeDefinitions/resource/function/extractInputsFromFunctionSql/extractInputsFromFunctionSql.test.ts: -------------------------------------------------------------------------------- 1 | import { extractSqlFromFile } from '../../../../common/extractSqlFromFile'; 2 | import { extractInputsFromFunctionSql } from './extractInputsFromFunctionSql'; 3 | 4 | describe('extractInputsFromFunctionSql', () => { 5 | describe('mysql', () => { 6 | it('should extract the inputs accurately in this example', async () => { 7 | const defs = extractInputsFromFunctionSql({ 8 | sql: await extractSqlFromFile({ 9 | filePath: `${__dirname}/../../../../__test_assets__/functions/upsert_image.mysql.sql`, 10 | }), 11 | }); 12 | expect(defs).toMatchSnapshot(); 13 | }); 14 | it('should extract the inputs accurately in this other example', async () => { 15 | const defs = extractInputsFromFunctionSql({ 16 | sql: await extractSqlFromFile({ 17 | filePath: `${__dirname}/../../../../__test_assets__/functions/upsert_suggestion.mysql.sql`, 18 | }), 19 | }); 20 | expect(defs).toMatchSnapshot(); 21 | }); 22 | it('should extract the inputs accurately in this other example again', async () => { 23 | const defs = extractInputsFromFunctionSql({ 24 | sql: await extractSqlFromFile({ 25 | filePath: `${__dirname}/../../../../__test_assets__/functions/hash_string.mysql.sql`, 26 | }), 27 | }); 28 | expect(defs).toMatchSnapshot(); 29 | }); 30 | }); 31 | describe('postgres', () => { 32 | it('should extract the inputs accurately in this example', async () => { 33 | const defs = extractInputsFromFunctionSql({ 34 | sql: await extractSqlFromFile({ 35 | filePath: `${__dirname}/../../../../__test_assets__/functions/upsert_photo.postgres.sql`, 36 | }), 37 | }); 38 | expect(defs).toMatchSnapshot(); 39 | }); 40 | it('should extract the inputs accurately when one of the inputs is an array type', async () => { 41 | const defs = extractInputsFromFunctionSql({ 42 | sql: await extractSqlFromFile({ 43 | filePath: `${__dirname}/../../../../__test_assets__/functions/upsert_job.postgres.sql`, 44 | }), 45 | }); 46 | expect(defs).toMatchSnapshot(); 47 | }); 48 | it('should extract the inputs accurately there is no input args', async () => { 49 | const defs = extractInputsFromFunctionSql({ 50 | sql: await extractSqlFromFile({ 51 | filePath: `${__dirname}/../../../../__test_assets__/functions/get_answer_to_life.postgres.sql`, 52 | }), 53 | }); 54 | expect(defs).toMatchSnapshot(); 55 | }); 56 | }); 57 | }); 58 | --------------------------------------------------------------------------------