├── .depcheckrc ├── .eslintrc ├── .github └── workflows │ ├── ci.yml │ └── production.yml ├── .gitignore ├── .husky └── pre-commit ├── .node-version ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── __snapshots__ ├── blog-camelPlural.snap ├── blog-camelSingular.snap ├── blog-noPrefixPks.snap ├── blog-noTimestamps.snap ├── blog-snakePlural.snap ├── blog-snakeSingular.snap ├── employee-dataset-camelPlural.snap ├── employee-dataset-camelSingular.snap ├── employee-dataset-noPrefixPks.snap ├── employee-dataset-noTimestamps.snap ├── employee-dataset-snakePlural.snap ├── employee-dataset-snakeSingular.snap ├── mariadb.snap ├── mssql.snap ├── mysql.snap ├── postgres.snap ├── sakila-camelPlural.snap ├── sakila-camelSingular.snap ├── sakila-noPrefixPks.snap ├── sakila-noTimestamps.snap ├── sakila-snakePlural.snap ├── sakila-snakeSingular.snap └── sqlite.snap ├── _headers ├── assets ├── edit-schema.png └── view-code.png ├── e2e ├── docker-compose.yml ├── migrations │ ├── __tests__ │ │ └── index.spec.ts │ ├── cases.ts │ └── schemaCases │ │ ├── blog.ts │ │ ├── employees.ts │ │ ├── index.ts │ │ ├── sakila.ts │ │ └── studentInfoSystem.ts └── tsconfig.json ├── jest.config.js ├── next-env.d.ts ├── next-sitemap.config.js ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── robots.txt └── site.webmanifest ├── src ├── api │ ├── meta.ts │ ├── schema │ │ ├── api.ts │ │ ├── examples │ │ │ ├── blog.ts │ │ │ ├── employees.ts │ │ │ ├── ids.ts │ │ │ ├── sakila.ts │ │ │ └── studentInfoSystem.ts │ │ ├── implementations │ │ │ └── localStorage │ │ │ │ ├── __fixtures__ │ │ │ │ ├── blogLegacy.ts │ │ │ │ ├── blogTranslatedFromLegacy.ts │ │ │ │ ├── blogTranslatedFromV1.ts │ │ │ │ ├── blogV1-0-1.ts │ │ │ │ └── blogV1.ts │ │ │ │ ├── __tests__ │ │ │ │ ├── index.spec.ts │ │ │ │ └── parse.spec.ts │ │ │ │ ├── index.ts │ │ │ │ ├── v0 │ │ │ │ ├── index.ts │ │ │ │ ├── schema.jtd.json │ │ │ │ └── translate.ts │ │ │ │ └── v1 │ │ │ │ ├── index.ts │ │ │ │ ├── schema.jtd.json │ │ │ │ └── translate.ts │ │ └── index.ts │ └── userPreferences │ │ ├── api.ts │ │ ├── examples │ │ ├── blog.ts │ │ ├── employees.ts │ │ ├── ids.ts │ │ └── sakila.ts │ │ ├── implementations │ │ └── localStorage │ │ │ ├── __fixtures__ │ │ │ ├── userPreferencesTranslatedFromV1.ts │ │ │ └── userPreferencesV1.ts │ │ │ ├── __tests__ │ │ │ ├── index.spec.ts │ │ │ └── parse.spec.ts │ │ │ ├── index.ts │ │ │ ├── parse.ts │ │ │ └── v1 │ │ │ ├── index.ts │ │ │ ├── schema.jtd.json │ │ │ └── translate.ts │ │ └── index.ts ├── core │ ├── codegen │ │ ├── __tests__ │ │ │ └── index.spec.ts │ │ └── index.ts │ ├── database │ │ ├── __tests__ │ │ │ └── index.spec.ts │ │ └── index.ts │ ├── files │ │ ├── __tests__ │ │ │ ├── __fixtures__ │ │ │ │ └── fileTree.ts │ │ │ ├── fileTree.spec.ts │ │ │ └── index.spec.ts │ │ ├── fileSystem.ts │ │ └── fileTree.ts │ ├── framework │ │ ├── __tests__ │ │ │ └── index.spec.ts │ │ └── index.ts │ ├── oss │ │ └── license.ts │ ├── schema │ │ ├── __tests__ │ │ │ ├── __fixtures__ │ │ │ │ ├── association.ts │ │ │ │ └── dataType.ts │ │ │ ├── associations.spec.ts │ │ │ ├── dataType.spec.ts │ │ │ └── schema.spec.ts │ │ ├── association.ts │ │ ├── dataType.ts │ │ ├── index.ts │ │ └── schema.ts │ └── validation │ │ ├── __tests__ │ │ └── schema.spec.ts │ │ ├── messages.ts │ │ └── schema.ts ├── frameworks │ ├── __fixtures__ │ │ └── schemas │ │ │ ├── associations.ts │ │ │ ├── dataTypes.ts │ │ │ ├── fields.ts │ │ │ └── pks.ts │ ├── sequelize │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ ├── config-camel-plural.shot │ │ │ │ ├── config-camel-singular.shot │ │ │ │ ├── config-no-prefix-pks.shot │ │ │ │ ├── config-no-timestamps.shot │ │ │ │ ├── config-paranoid-no-timestamps.shot │ │ │ │ ├── config-paranoid-snake-plural.shot │ │ │ │ ├── config-snake-plural.shot │ │ │ │ ├── config-snake-singular.shot │ │ │ │ ├── dialect-mariadb.shot │ │ │ │ ├── dialect-mssql.shot │ │ │ │ ├── dialect-mysql.shot │ │ │ │ ├── dialect-postgres.shot │ │ │ │ ├── dialect-sqlite.shot │ │ │ │ ├── schema-blog.shot │ │ │ │ ├── schema-employees.shot │ │ │ │ └── schema-sakila.shot │ │ │ ├── generateConfigs.spec.ts │ │ │ ├── generateDialects.spec.ts │ │ │ ├── generateSchemas.spec.ts │ │ │ └── index.spec.ts │ │ ├── generate.ts │ │ ├── index.ts │ │ ├── templates │ │ │ ├── config.ts │ │ │ ├── db.ts │ │ │ ├── gitignore.ts │ │ │ ├── initModels.ts │ │ │ ├── migrations │ │ │ │ └── createModel.ts │ │ │ ├── model │ │ │ │ ├── imports.ts │ │ │ │ ├── index.ts │ │ │ │ └── modelClass.ts │ │ │ ├── packageJson.ts │ │ │ ├── readme.ts │ │ │ ├── server.ts │ │ │ ├── tsconfig.ts │ │ │ └── types.ts │ │ └── utils │ │ │ ├── associations.ts │ │ │ ├── config.ts │ │ │ ├── dataTypes.ts │ │ │ ├── field.ts │ │ │ ├── migrations.ts │ │ │ └── model.ts │ └── testUtils.ts ├── io │ ├── __tests__ │ │ ├── copy.spec.ts │ │ └── download.spec.ts │ ├── copy.ts │ └── download.ts ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── index.tsx │ ├── privacy.tsx │ └── schema │ │ ├── [slug].tsx │ │ ├── index.tsx │ │ └── new.tsx ├── test-utils │ ├── files │ │ └── index.ts │ ├── generators │ │ └── index.ts │ ├── index.ts │ ├── jestSetup.ts │ ├── next │ │ └── index.ts │ ├── project │ │ ├── index.ts │ │ ├── npm.ts │ │ └── project.ts │ └── sql │ │ ├── connection.ts │ │ ├── index.ts │ │ ├── mssql.ts │ │ ├── mysql.ts │ │ ├── postgres.ts │ │ └── sqlite.ts ├── theme │ └── globals.css ├── ui │ ├── components │ │ ├── Breadcrumbs │ │ │ ├── Breadcrumbs.tsx │ │ │ └── index.ts │ │ ├── Code │ │ │ ├── Code.tsx │ │ │ ├── code.module.css │ │ │ └── index.ts │ │ ├── CodeExplorer │ │ │ └── CodeExplorer.tsx │ │ ├── DbOptionsForm │ │ │ ├── DbOptionsForm.tsx │ │ │ └── index.ts │ │ ├── ErrorBoundary │ │ │ ├── ErrorBoundary.tsx │ │ │ └── index.ts │ │ ├── FileTreeView │ │ │ ├── FileTreeView.tsx │ │ │ ├── index.ts │ │ │ └── useFileTree.ts │ │ ├── Flyout │ │ │ ├── Flyout.tsx │ │ │ └── index.ts │ │ ├── Layout │ │ │ ├── Footer.tsx │ │ │ ├── Header.tsx │ │ │ ├── Layout.tsx │ │ │ ├── Metadata.tsx │ │ │ ├── PageWrapper.tsx │ │ │ └── index.tsx │ │ ├── Markdown │ │ │ ├── Markdown.tsx │ │ │ ├── index.ts │ │ │ └── markdown.module.css │ │ ├── Modal │ │ │ ├── Modal.tsx │ │ │ └── index.ts │ │ ├── ModelForm │ │ │ ├── AssociationFieldset.tsx │ │ │ ├── FieldFieldset.tsx │ │ │ ├── ModelFieldset.tsx │ │ │ ├── ModelForm.tsx │ │ │ └── index.ts │ │ ├── ModelView │ │ │ ├── AssociationView.tsx │ │ │ ├── FieldView.tsx │ │ │ ├── ModelView.tsx │ │ │ └── index.ts │ │ ├── Portal │ │ │ ├── Portal.tsx │ │ │ └── index.ts │ │ ├── SchemaForm │ │ │ ├── SchemaForm.tsx │ │ │ └── index.tsx │ │ ├── SchemaView │ │ │ ├── SchemaView.tsx │ │ │ └── index.ts │ │ ├── SequelizeUiLogo │ │ │ ├── SequelizeUiLogo.tsx │ │ │ └── index.ts │ │ ├── form │ │ │ ├── Button │ │ │ │ ├── Button.tsx │ │ │ │ └── index.ts │ │ │ ├── Checkbox │ │ │ │ ├── Checkbox.tsx │ │ │ │ └── index.ts │ │ │ ├── IconButton │ │ │ │ ├── IconButton.tsx │ │ │ │ └── index.ts │ │ │ ├── NumberInput │ │ │ │ ├── NumberInput.tsx │ │ │ │ └── index.ts │ │ │ ├── PanelButton │ │ │ │ ├── PanelButton.tsx │ │ │ │ └── index.ts │ │ │ ├── Radio │ │ │ │ ├── Radio.tsx │ │ │ │ └── index.ts │ │ │ ├── Select │ │ │ │ ├── Select.tsx │ │ │ │ └── index.ts │ │ │ ├── TextArea │ │ │ │ ├── TextArea.tsx │ │ │ │ └── index.ts │ │ │ ├── TextInput │ │ │ │ ├── TextInput.tsx │ │ │ │ └── index.ts │ │ │ ├── ToggleButton │ │ │ │ ├── ToggleButton.tsx │ │ │ │ └── index.ts │ │ │ └── shared │ │ │ │ ├── FormError.tsx │ │ │ │ ├── Input.tsx │ │ │ │ ├── InputWrapper.tsx │ │ │ │ ├── options.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ ├── icons │ │ │ ├── AcademicCapIcon.tsx │ │ │ ├── Adjustments.tsx │ │ │ ├── ArrowLeft.tsx │ │ │ ├── Chevron.tsx │ │ │ ├── Clock.tsx │ │ │ ├── Close.tsx │ │ │ ├── CloseCircle.tsx │ │ │ ├── Code.tsx │ │ │ ├── Collection.tsx │ │ │ ├── Computer.tsx │ │ │ ├── Copy.tsx │ │ │ ├── Cube.tsx │ │ │ ├── Eye.tsx │ │ │ ├── Film.tsx │ │ │ ├── FloppyDisc.tsx │ │ │ ├── Folder.tsx │ │ │ ├── Git.tsx │ │ │ ├── GitHub.tsx │ │ │ ├── Info.tsx │ │ │ ├── JavaScript.tsx │ │ │ ├── Json.tsx │ │ │ ├── Language.tsx │ │ │ ├── Markdown.tsx │ │ │ ├── Moon.tsx │ │ │ ├── Pencil.tsx │ │ │ ├── Plus.tsx │ │ │ ├── Rss.tsx │ │ │ ├── Save.tsx │ │ │ ├── Selector.tsx │ │ │ ├── Settings.tsx │ │ │ ├── Sun.tsx │ │ │ ├── Svg.tsx │ │ │ ├── SwitchHorizontal.tsx │ │ │ ├── Trash.tsx │ │ │ ├── TypeScript.tsx │ │ │ └── UserGroup.tsx │ │ └── menus │ │ │ ├── ActionMenu │ │ │ ├── ActionMenu.tsx │ │ │ ├── Dot.tsx │ │ │ ├── StackButton.tsx │ │ │ └── index.ts │ │ │ ├── Menu │ │ │ ├── Menu.tsx │ │ │ └── index.ts │ │ │ ├── MenuButton │ │ │ ├── MenuButton.tsx │ │ │ └── index.ts │ │ │ └── MenuPanel │ │ │ ├── MenuPanel.tsx │ │ │ └── index.ts │ ├── hocs │ │ └── withLayout.tsx │ ├── hooks │ │ ├── __tests__ │ │ │ ├── useGeneratedCode.spec.ts │ │ │ ├── useIsOpen.spec.ts │ │ │ ├── usePrevious.spec.ts │ │ │ └── useRoute.spec.ts │ │ ├── useAsync.ts │ │ ├── useDarkMode.ts │ │ ├── useDidMount.ts │ │ ├── useGeneratedCode.ts │ │ ├── useIsOpen.ts │ │ ├── useLockScroll.ts │ │ ├── useOnClickOutside.ts │ │ ├── usePrevious.ts │ │ ├── useRoute.ts │ │ ├── useSetOnce.ts │ │ └── useToggle.ts │ ├── layouts │ │ ├── HomeLayout │ │ │ ├── About.tsx │ │ │ ├── ExampleSchemas.tsx │ │ │ ├── HomeLayout.tsx │ │ │ ├── Intro.tsx │ │ │ ├── MySchemaLinks.tsx │ │ │ ├── MySchemas.tsx │ │ │ ├── SchemaStorageInfo.tsx │ │ │ ├── SchemasError.tsx │ │ │ ├── constants.ts │ │ │ └── index.tsx │ │ └── SchemaLayout │ │ │ ├── CodeViewerControls.tsx │ │ │ ├── SchemaCodeToggle.tsx │ │ │ ├── SchemaLayout.tsx │ │ │ ├── SchemaLayoutContent.tsx │ │ │ ├── SchemaLayoutControls.tsx │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── useSchemaLayout.ts │ ├── lib │ │ ├── alert │ │ │ ├── AlertContext.tsx │ │ │ ├── index.tsx │ │ │ ├── internal │ │ │ │ ├── AlertDisplay.tsx │ │ │ │ ├── AlertsContainer.tsx │ │ │ │ ├── alert.ts │ │ │ │ └── useAlertState.ts │ │ │ └── types.ts │ │ └── focus │ │ │ ├── FocusContext.tsx │ │ │ ├── index.ts │ │ │ └── internal │ │ │ └── useTrapFocusState.ts │ ├── routing │ │ ├── ExternalLink.tsx │ │ ├── RouteLink.tsx │ │ ├── __tests__ │ │ │ ├── navigation.spec.ts │ │ │ └── routes.spec.ts │ │ ├── navigation.ts │ │ └── routes.ts │ ├── styles │ │ ├── breadcrumbs.module.css │ │ ├── classnames │ │ │ ├── __generated__ │ │ │ │ └── tailwindcss-classnames.ts │ │ │ ├── index.ts │ │ │ └── utils.ts │ │ └── utils.ts │ └── utils │ │ └── darkMode.ts └── utils │ ├── __tests__ │ ├── array.spec.ts │ ├── dateTime.spec.ts │ ├── enum.spec.ts │ ├── object.spec.ts │ ├── string.spec.ts │ └── url.spec.ts │ ├── array.ts │ ├── dateTime.ts │ ├── dom.ts │ ├── enum.ts │ ├── functions.ts │ ├── localStorage.ts │ ├── object.ts │ ├── string.ts │ ├── types.ts │ └── url.ts ├── tailwind.config.js ├── tasks.md ├── tsconfig.check.json └── tsconfig.json /.depcheckrc: -------------------------------------------------------------------------------- 1 | ignores: [ 2 | "@src/*", 3 | # jest.config.js: setupFiles 4 | "jest-localstorage-mock", 5 | # package.json: npm run build:sitemap 6 | "source-map-support" 7 | ] -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [], 5 | "extends": [ 6 | "eslint:recommended", 7 | "next", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:prettier/recommended" 10 | ], 11 | "settings": { 12 | "react": { 13 | "version": "detect" 14 | } 15 | }, 16 | "rules": { 17 | "@typescript-eslint/no-unused-vars": [ 18 | "error", 19 | { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } 20 | ], 21 | "@typescript-eslint/no-inferrable-types": "off" 22 | }, 23 | "overrides": [ 24 | { 25 | "files": [ 26 | "jest.config.js", 27 | "next.config.js", 28 | "next-sitemap.js", 29 | "postcss.config.js", 30 | "tailwind.config.js" 31 | ], 32 | "rules": { 33 | "@typescript-eslint/no-var-requires": "off" 34 | }, 35 | "env": { 36 | "node": true 37 | } 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # TypeScript 2 | /.buildcache 3 | 4 | # dependencies 5 | /node_modules 6 | 7 | # misc 8 | .DS_Store 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local 13 | 14 | npm-debug.log* 15 | .eslintcache 16 | 17 | # Next.js 18 | /.next 19 | /out 20 | 21 | # Istanbul/NYC 22 | .nyc_output 23 | /coverage 24 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | ./node_modules/.bin/lint-staged && npm run check:types 5 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.9.0 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .buildcache 2 | .eslintcache 3 | .github 4 | .next 5 | coverage 6 | node_modules 7 | out -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Tom Schuster 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sequelize UI 2 | 3 | https://sequelizeui.app/ 4 | 5 | Sequelize UI is a [Sequelize ORM](https://sequelize.org/) code generator, which generates a full Node.js TypeScript project, entirely in the browser. Use the schema editor to design your database tables, fields and associations, then preview the Sequelize models and migrations in the code viewer before downloading the project as a zip file or copying code from individual files. 6 | 7 | You can customize the generated Sequelize code with the following database configurations: 8 | 9 | - PostgreSQL, MySQL, MariaDB, SQLite or Microsoft SQL Server dialects. 10 | - Singular or plural table names 11 | - camelCase or snake_case table and column names. 12 | - Table name prefixed primary keys or plain id primary keys. 13 | - Created/updated timestamps or no timestamps. 14 | 15 | ![View your code](./assets/view-code.png) ![Edit your schema](./assets/edit-schema.png) 16 | 17 | ## Usage 18 | 19 | To use Sequelize UI, either go to https://sequelizeui.app or run the project locally with: 20 | 21 | ```sh 22 | npm ci 23 | npm run build 24 | npm npx serve@latest out 25 | ``` 26 | 27 | Then go to http://localhost:3000 28 | 29 | ### Plain JavaScript 30 | 31 | Sequelize UI currenly only generates TypeScript Sequelize code, however, an older version is still available at https://js.sequelizeui.app/ which generates plain JavaScript Sequelize code. Future support for JavaScript is planned and can be tracked in [tasks.md](./tasks.md). 32 | -------------------------------------------------------------------------------- /_headers: -------------------------------------------------------------------------------- 1 | / 2 | Content-Security-Policy: default-src 'self'; require-trusted-types-for 'script'; frame-ancestors 'none' 3 | Permissions-Policy: document-domain=() 4 | Referrer-Policy: origin 5 | Strict-Transport-Security: max-age=63072000; includeSubDomains; preload 6 | X-Content-Type-Options: nosniff 7 | X-Frame-Options: DENY 8 | 9 | /schemas 10 | Content-Security-Policy: default-src 'self'; require-trusted-types-for 'script'; frame-ancestors 'none' 11 | Permissions-Policy: document-domain=() 12 | Referrer-Policy: origin 13 | Strict-Transport-Security: max-age=63072000; includeSubDomains; preload 14 | X-Content-Type-Options: nosniff 15 | X-Frame-Options: DENY 16 | 17 | 18 | /schemas/* 19 | Content-Security-Policy: default-src 'self'; require-trusted-types-for 'script'; frame-ancestors 'none' 20 | Permissions-Policy: document-domain=() 21 | Referrer-Policy: origin 22 | Strict-Transport-Security: max-age=63072000; includeSubDomains; preload 23 | X-Content-Type-Options: nosniff 24 | X-Frame-Options: DENY 25 | 26 | https://:project.pages.dev/* 27 | X-Robots-Tag: noindex -------------------------------------------------------------------------------- /assets/edit-schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomjschuster/sequelize-ui/3df3b43e39980ab5c2b84780a4f3d00b314cab8d/assets/edit-schema.png -------------------------------------------------------------------------------- /assets/view-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomjschuster/sequelize-ui/3df3b43e39980ab5c2b84780a4f3d00b314cab8d/assets/view-code.png -------------------------------------------------------------------------------- /e2e/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | services: 3 | mariadb: 4 | image: mariadb:latest 5 | ports: 6 | - '3306:3306' 7 | environment: 8 | MYSQL_ROOT_PASSWORD: root 9 | 10 | mssql: 11 | image: mcr.microsoft.com/azure-sql-edge 12 | ports: 13 | - '1433:1433' 14 | environment: 15 | SA_PASSWORD: 'Password1' 16 | ACCEPT_EULA: 'Y' 17 | 18 | postgres: 19 | image: postgres 20 | ports: 21 | - '5432:5432' 22 | environment: 23 | POSTGRES_PASSWORD: postgres 24 | -------------------------------------------------------------------------------- /e2e/migrations/cases.ts: -------------------------------------------------------------------------------- 1 | import { DbCaseStyle, DbNounForm, DbOptions, SqlDialect } from '@src/core/database' 2 | import { Schema } from '@src/core/schema' 3 | 4 | export type ExpectedSchemaCase = { 5 | schema: Schema 6 | tableColumns: ExpectedColumns 7 | } 8 | 9 | type DbOptionsCase = keyof typeof dbOptionsCases 10 | 11 | type ColumnCase = string | [string, SqlDialect[]] 12 | 13 | export function columnCaseToColumn(columnCase: ColumnCase, sqlDialect: SqlDialect): string | null { 14 | if (typeof columnCase === 'string') return columnCase 15 | if (columnCase[1].includes(sqlDialect)) return columnCase[0] 16 | return null 17 | } 18 | 19 | export type ExpectedColumns = { 20 | [Key in DbOptionsCase]: Record 21 | } 22 | 23 | const dbOptionsCases = { 24 | snakePlural: { 25 | prefixPks: null, 26 | timestamps: true, 27 | caseStyle: DbCaseStyle.Snake, 28 | nounForm: DbNounForm.Plural, 29 | migrations: true, 30 | }, 31 | snakeSingular: { 32 | prefixPks: null, 33 | timestamps: true, 34 | caseStyle: DbCaseStyle.Snake, 35 | nounForm: DbNounForm.Singular, 36 | migrations: true, 37 | }, 38 | camelPlural: { 39 | prefixPks: null, 40 | timestamps: true, 41 | caseStyle: DbCaseStyle.Camel, 42 | nounForm: DbNounForm.Plural, 43 | migrations: true, 44 | }, 45 | camelSingular: { 46 | prefixPks: null, 47 | timestamps: true, 48 | caseStyle: DbCaseStyle.Camel, 49 | nounForm: DbNounForm.Singular, 50 | migrations: true, 51 | }, 52 | noTimestamps: { 53 | prefixPks: null, 54 | timestamps: false, 55 | caseStyle: DbCaseStyle.Snake, 56 | nounForm: DbNounForm.Plural, 57 | migrations: true, 58 | }, 59 | prefixPks: { 60 | prefixPks: true, 61 | timestamps: true, 62 | caseStyle: DbCaseStyle.Snake, 63 | nounForm: DbNounForm.Plural, 64 | migrations: true, 65 | }, 66 | } as const 67 | 68 | export const cases = Object.entries(dbOptionsCases) as [ 69 | DbOptionsCase, 70 | Omit, 71 | ][] 72 | -------------------------------------------------------------------------------- /e2e/migrations/schemaCases/index.ts: -------------------------------------------------------------------------------- 1 | import { ExpectedSchemaCase } from '../cases' 2 | import { default as blogCases } from './blog' 3 | import { default as employeesCases } from './employees' 4 | import { default as sakilaCases } from './sakila' 5 | import { default as studentInfoSystemCases } from './studentInfoSystem' 6 | 7 | const cases: ExpectedSchemaCase[] = [blogCases, employeesCases, sakilaCases, studentInfoSystemCases] 8 | 9 | export default cases 10 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | }, 6 | "include": ["**/*.ts", "**/*.tsx"] 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest') 2 | 3 | const createJestConfig = nextJest({ dir: './' }) 4 | 5 | const customConfig = { 6 | roots: ['/src', '/e2e'], 7 | collectCoverageFrom: ['src/**/*.{ts,tsx}'], 8 | coveragePathIgnorePatterns: [ 9 | 'src/test-utils', 10 | '.*\\.d\\.ts', 11 | '__fixtures__', 12 | '__tests__', 13 | '__generated__', 14 | // write tests after completing ui 15 | 'src/pages', 16 | 'src/ui', 17 | ], 18 | moduleNameMapper: { '@src/(.*)': '/src/$1' }, 19 | testPathIgnorePatterns: ['src/typings', 'src/test-utils', '__fixtures__'], 20 | setupFiles: ['jest-localstorage-mock'], 21 | setupFilesAfterEnv: ['/src/test-utils/jestSetup.ts'], 22 | resetMocks: false, 23 | moduleDirectories: ['node_modules', '/'], 24 | testEnvironment: 'jest-environment-jsdom', 25 | } 26 | 27 | const jestConfig = async () => { 28 | const config = await createJestConfig(customConfig)() 29 | 30 | const esModules = ['nanoid'].join('|') 31 | 32 | const transformIgnorePatterns = config.transformIgnorePatterns.map((p) => 33 | p === '/node_modules/' ? `node_modules/(?!${esModules})` : p, 34 | ) 35 | 36 | return { ...config, transformIgnorePatterns } 37 | } 38 | 39 | module.exports = jestConfig 40 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | 3 | module.exports = { 4 | siteUrl: 'https://sequelizeui.app', 5 | generateRobotsTxt: true, 6 | generateIndexSitemap: false, 7 | output: 'export', 8 | exclude: ['/schema'], 9 | } 10 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 2 | enabled: process.env.ANALYZE === 'true', 3 | }) 4 | 5 | module.exports = withBundleAnalyzer({ 6 | reactStrictMode: true, 7 | output: 'export', 8 | }) 9 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomjschuster/sequelize-ui/3df3b43e39980ab5c2b84780a4f3d00b314cab8d/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomjschuster/sequelize-ui/3df3b43e39980ab5c2b84780a4f3d00b314cab8d/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomjschuster/sequelize-ui/3df3b43e39980ab5c2b84780a4f3d00b314cab8d/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomjschuster/sequelize-ui/3df3b43e39980ab5c2b84780a4f3d00b314cab8d/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomjschuster/sequelize-ui/3df3b43e39980ab5c2b84780a4f3d00b314cab8d/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomjschuster/sequelize-ui/3df3b43e39980ab5c2b84780a4f3d00b314cab8d/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # * 2 | User-agent: * 3 | Allow: / 4 | 5 | # Host 6 | Host: https://sequelizeui.app 7 | 8 | # Sitemaps 9 | Sitemap: https://sequelizeui.app/sitemap.xml 10 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tools.w3cub.com", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /src/api/schema/api.ts: -------------------------------------------------------------------------------- 1 | import { Model, Schema } from '@src/core/schema' 2 | 3 | export interface SchemaApi { 4 | listSchemas(): Promise 5 | getSchema(id: string): Promise 6 | createSchema(schemaPayload: Schema, fork?: boolean): Promise 7 | updateSchema(schema: Schema): Promise 8 | updateModel(model: Model, schema: Schema): Promise 9 | deleteSchema(id: string): Promise 10 | deleteAllSchemas(): Promise 11 | } 12 | 13 | export const SCHEMA_NOT_FOUND_ERROR = '[Schema API] Schema not found' 14 | -------------------------------------------------------------------------------- /src/api/schema/examples/ids.ts: -------------------------------------------------------------------------------- 1 | export const BLOG_ID = 'cNkVZ3G1M' 2 | export const EMPLOYEES_ID = 'XSkFjXPXcg' 3 | export const SAKILA_ID = 'Wdv4lUffhT' 4 | export const STUDENT_INFO_SYSTEM_ID = 'qi75SYu3dn' 5 | -------------------------------------------------------------------------------- /src/api/schema/implementations/localStorage/__tests__/parse.spec.ts: -------------------------------------------------------------------------------- 1 | import { deepDropKeys } from '@src/utils/object' 2 | import { blogLegacy } from '../__fixtures__/blogLegacy' 3 | import { blogTranslatedFromLegacy } from '../__fixtures__/blogTranslatedFromLegacy' 4 | import { blogTranslatedFromV1 } from '../__fixtures__/blogTranslatedFromV1' 5 | import { blogV1 } from '../__fixtures__/blogV1' 6 | import { blogV1_0_1 } from '../__fixtures__/blogV1-0-1' 7 | import { fromV0 } from '../v0/translate' 8 | import { fromV1, toV1 } from '../v1/translate' 9 | 10 | describe('translate (legacy)', () => { 11 | // time stamps are added when migrating from legacy 12 | const dropKeys = ['createdAt', 'updatedAt'] 13 | 14 | it('migrates the blog schema', () => { 15 | const { id: _id1, ...result } = deepDropKeys(fromV0(blogLegacy), dropKeys) 16 | const { id: _id2, ...expected } = deepDropKeys(blogTranslatedFromLegacy, dropKeys) 17 | 18 | expect(result).toEqual(expected) 19 | }) 20 | }) 21 | 22 | describe('translate (v1)', () => { 23 | it('translates to', () => { 24 | expect(fromV1(blogV1)).toEqual(blogTranslatedFromV1) 25 | }) 26 | 27 | it('translates from (1.0)', () => { 28 | const result = deepDropKeys(toV1(blogTranslatedFromV1), ['softDelete']) 29 | expect(result).toEqual(blogV1) 30 | }) 31 | 32 | it('translates from (1.0.1)', () => { 33 | expect(toV1(blogTranslatedFromV1)).toEqual(blogV1_0_1) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/api/schema/index.ts: -------------------------------------------------------------------------------- 1 | import LocalStorageSchemaApi from './implementations/localStorage' 2 | const api = new LocalStorageSchemaApi() 3 | export default api 4 | -------------------------------------------------------------------------------- /src/api/userPreferences/api.ts: -------------------------------------------------------------------------------- 1 | import { DbOptions } from '@src/core/database' 2 | 3 | export type UserPreferences = { 4 | defaultDbOptions: DbOptions 5 | } 6 | 7 | export interface UserPreferencesApi { 8 | getDefaultDbOptions: () => Promise 9 | updateDefaultDbOptions: (dbOptions: DbOptions) => Promise 10 | clearPreferences: () => Promise 11 | } 12 | -------------------------------------------------------------------------------- /src/api/userPreferences/examples/ids.ts: -------------------------------------------------------------------------------- 1 | export const BLOG_ID = 'cNkVZ3G1M' 2 | export const EMPLOYEES_ID = 'XSkFjXPXcg' 3 | export const SAKILA_ID = 'Wdv4lUffhT' 4 | -------------------------------------------------------------------------------- /src/api/userPreferences/implementations/localStorage/__fixtures__/userPreferencesTranslatedFromV1.ts: -------------------------------------------------------------------------------- 1 | import { UserPreferences } from '@src/api/userPreferences/api' 2 | import { DbCaseStyle, DbNounForm, SqlDialect } from '@src/core/database' 3 | 4 | export const userPreferencesTranslatedFromV1: UserPreferences = { 5 | defaultDbOptions: { 6 | sqlDialect: SqlDialect.MariaDb, 7 | caseStyle: DbCaseStyle.Camel, 8 | nounForm: DbNounForm.Plural, 9 | migrations: true, 10 | timestamps: false, 11 | prefixPks: false, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /src/api/userPreferences/implementations/localStorage/__fixtures__/userPreferencesV1.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DbOptionsCaseStyle, 3 | DbOptionsNounForm, 4 | DbOptionsSqlDialect, 5 | UserPreferencesV1, 6 | } from '../v1' 7 | 8 | export const userPreferencesV1: UserPreferencesV1 = { 9 | defaultDbOptions: { 10 | sqlDialect: DbOptionsSqlDialect.Mariadb, 11 | caseStyle: DbOptionsCaseStyle.Camel, 12 | nounForm: DbOptionsNounForm.Plural, 13 | migrations: true, 14 | timestamps: false, 15 | prefixPks: false, 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /src/api/userPreferences/implementations/localStorage/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DbCaseStyle, 3 | DbNounForm, 4 | DbOptions, 5 | defaultDbOptions, 6 | SqlDialect, 7 | } from '@src/core/database' 8 | import LocalStorageSchemaApi from '..' 9 | 10 | const userPreferencesApi = new LocalStorageSchemaApi() 11 | 12 | describe('userPreferences api', () => { 13 | beforeEach(() => { 14 | localStorage.clear() 15 | jest.clearAllMocks() 16 | }) 17 | 18 | describe('getDefaultDbOptions', () => { 19 | it('should return default options when none are found', async () => { 20 | await expect(userPreferencesApi.getDefaultDbOptions()).resolves.toEqual(defaultDbOptions) 21 | }) 22 | 23 | it('should return persisted options', async () => { 24 | const customOptions: DbOptions = { 25 | ...defaultDbOptions, 26 | sqlDialect: SqlDialect.MsSql, 27 | prefixPks: true, 28 | caseStyle: DbCaseStyle.Camel, 29 | nounForm: DbNounForm.Singular, 30 | } 31 | await userPreferencesApi.updateDefaultDbOptions(customOptions) 32 | await expect(userPreferencesApi.getDefaultDbOptions()).resolves.toEqual(customOptions) 33 | }) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/api/userPreferences/implementations/localStorage/__tests__/parse.spec.ts: -------------------------------------------------------------------------------- 1 | import { fromV1, toV1 } from '../v1/translate' 2 | import { userPreferencesTranslatedFromV1 } from '../__fixtures__/userPreferencesTranslatedFromV1' 3 | import { userPreferencesV1 } from '../__fixtures__/userPreferencesV1' 4 | 5 | describe('translate (v1)', () => { 6 | it('translates to', () => { 7 | expect(fromV1(userPreferencesV1)).toEqual(userPreferencesTranslatedFromV1) 8 | }) 9 | 10 | it('translates from', () => { 11 | const result = toV1(userPreferencesTranslatedFromV1) 12 | expect(result).toEqual(userPreferencesV1) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/api/userPreferences/implementations/localStorage/index.ts: -------------------------------------------------------------------------------- 1 | import { DbOptions, defaultDbOptions } from '@src/core/database' 2 | import { get, lsKey, remove, set } from '@src/utils/localStorage' 3 | import { UserPreferences, UserPreferencesApi } from '../../api' 4 | import { parseUserPreferences } from './parse' 5 | import { toV1 } from './v1/translate' 6 | 7 | export default class LocalStorageUserPreferencesApi implements UserPreferencesApi { 8 | async getDefaultDbOptions(): Promise { 9 | try { 10 | const userPreferences = await getUserPreferences() 11 | return userPreferences ? userPreferences.defaultDbOptions : defaultDbOptions 12 | } catch (e) { 13 | console.error(e) 14 | this.clearPreferences() 15 | return defaultDbOptions 16 | } 17 | } 18 | async updateDefaultDbOptions(defaultDbOptions: DbOptions): Promise { 19 | await setUserPreferences({ defaultDbOptions }) 20 | return defaultDbOptions 21 | } 22 | 23 | async clearPreferences(): Promise { 24 | remove(userPreferencesKey()) 25 | } 26 | } 27 | 28 | function userPreferencesKey(): string { 29 | return lsKey('userPreferences') 30 | } 31 | 32 | async function setUserPreferences(userPreferences: UserPreferences): Promise { 33 | const payload = toV1(userPreferences) 34 | return set(userPreferencesKey(), payload) 35 | } 36 | 37 | async function getUserPreferences(): Promise { 38 | const data = get(userPreferencesKey()) 39 | return data ? await parseUserPreferences(data) : null 40 | } 41 | -------------------------------------------------------------------------------- /src/api/userPreferences/implementations/localStorage/parse.ts: -------------------------------------------------------------------------------- 1 | import { Schema as JtdSchema, validate } from 'jtd' 2 | import { UserPreferences } from '../../api' 3 | import { UserPreferencesV1 } from './v1' 4 | import v1JtdSchema from './v1/schema.jtd.json' 5 | import { fromV1 } from './v1/translate' 6 | 7 | export function parseUserPreferences(userPreferences: unknown): Promise { 8 | return parseV1(userPreferences) 9 | } 10 | 11 | export async function parseV1(userPreferences: unknown): Promise { 12 | const errors = validate(v1JtdSchema as JtdSchema, userPreferences) 13 | if (errors.length > 0) return await Promise.reject(errors) 14 | return fromV1(userPreferences as UserPreferencesV1) 15 | } 16 | -------------------------------------------------------------------------------- /src/api/userPreferences/implementations/localStorage/v1/index.ts: -------------------------------------------------------------------------------- 1 | // Code generated by jtd-codegen for TypeScript v0.2.1 2 | 3 | export interface UserPreferencesV1 { 4 | defaultDbOptions: DbOptions 5 | } 6 | 7 | export enum DbOptionsCaseStyle { 8 | Camel = 'camel', 9 | Snake = 'snake', 10 | } 11 | 12 | export enum DbOptionsNounForm { 13 | Plural = 'plural', 14 | Singular = 'singular', 15 | } 16 | 17 | export enum DbOptionsSqlDialect { 18 | Mariadb = 'mariadb', 19 | Mssql = 'mssql', 20 | Mysql = 'mysql', 21 | Postgres = 'postgres', 22 | Sqlite = 'sqlite', 23 | } 24 | 25 | export interface DbOptions { 26 | caseStyle: DbOptionsCaseStyle 27 | migrations: boolean 28 | nounForm: DbOptionsNounForm 29 | prefixPks: boolean | null 30 | sqlDialect: DbOptionsSqlDialect 31 | timestamps: boolean 32 | } 33 | -------------------------------------------------------------------------------- /src/api/userPreferences/implementations/localStorage/v1/schema.jtd.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "version": "1.0.0" 4 | }, 5 | "properties": { 6 | "defaultDbOptions": { "ref": "dbOptions" } 7 | }, 8 | "definitions": { 9 | "dbOptions": { 10 | "additionalProperties": true, 11 | "properties": { 12 | "sqlDialect": { "enum": ["postgres", "sqlite", "mysql", "mariadb", "mssql"] }, 13 | "prefixPks": { "nullable": true, "type": "boolean" }, 14 | "timestamps": { "type": "boolean" }, 15 | "caseStyle": { "enum": ["snake", "camel"] }, 16 | "nounForm": { "enum": ["singular", "plural"] }, 17 | "migrations": { "type": "boolean" } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/api/userPreferences/index.ts: -------------------------------------------------------------------------------- 1 | import LocalStorageSchemaApi from './implementations/localStorage' 2 | const api = new LocalStorageSchemaApi() 3 | export default api 4 | -------------------------------------------------------------------------------- /src/core/codegen/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { blank, indent, Line, lines, LinesOptions } from '..' 2 | 3 | describe('codegen', () => { 4 | describe('blank', () => { 5 | it('returns an empty string', () => { 6 | expect(blank()).toEqual('') 7 | }) 8 | }) 9 | describe('lines', () => { 10 | const cases: [value: Line, options: LinesOptions | undefined, expected: string][] = [ 11 | [[], {}, ''], 12 | [[[]], {}, ''], 13 | [['foo'], {}, 'foo'], 14 | [['foo', 'bar', 'baz'], {}, 'foo\nbar\nbaz'], 15 | [['foo', 'bar', ['baz']], {}, 'foo\nbar\nbaz'], 16 | [['foo', 'bar', 'baz'], {}, 'foo\nbar\nbaz'], 17 | [['foo', 'bar', 'baz'], { separator: ',' }, 'foo,\nbar,\nbaz'], 18 | [['foo', 'bar', 'baz'], { depth: 2 }, ' foo\n bar\n baz'], 19 | [['foo', 'bar', 'baz'], { prefix: '^' }, '^foo\n^bar\n^baz'], 20 | [['foo', 'bar', 'baz'], undefined, 'foo\nbar\nbaz'], 21 | ] 22 | describe.each(cases)('', (value, opts, expected) => { 23 | it(`(${value}, ${JSON.stringify(opts)})=== ${expected}`, () => { 24 | expect(lines(value, opts)).toEqual(expected) 25 | }) 26 | }) 27 | }) 28 | describe('indent', () => { 29 | const cases: [level: number, value: string, prefix: string | undefined, expected: string][] = [ 30 | [0, 'foo', undefined, 'foo'], 31 | [0, 'foo\nbar', undefined, 'foo\nbar'], 32 | [1, 'foo', undefined, ' foo'], 33 | [1, 'foo\nbar', undefined, ' foo\n bar'], 34 | [2, 'foo', undefined, ' foo'], 35 | [2, 'foo\nbar', undefined, ' foo\n bar'], 36 | [2, 'foo\n bar', undefined, ' foo\n bar'], 37 | [2, 'foo\n bar', ',', ', foo\n, bar'], 38 | ] 39 | describe.each(cases)('', (level, value, prefix, expected) => { 40 | it(`(${level}, ${value}, ${prefix}) === ${expected}`, () => { 41 | expect(indent(level, value, prefix)).toEqual(expected) 42 | }) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/core/codegen/index.ts: -------------------------------------------------------------------------------- 1 | export function indent(depth: number, value: string, prefix = ''): string { 2 | return value 3 | .split('\n') 4 | .map((x) => prefix + ' '.repeat(depth) + x) 5 | .join('\n') 6 | } 7 | 8 | export function blank(): string { 9 | return '' 10 | } 11 | 12 | export type LinesOptions = { 13 | separator?: string 14 | depth?: number 15 | prefix?: string 16 | } 17 | 18 | export type Line = NestedArray 19 | 20 | const defaultLinesOptions: LinesOptions = { 21 | separator: '', 22 | depth: 0, 23 | } 24 | export function lines( 25 | xs: Line, 26 | { separator = '', depth = 0, prefix = '' }: LinesOptions = defaultLinesOptions, 27 | ): string { 28 | return indent( 29 | depth, 30 | flatten(xs) 31 | .filter((x): x is string => x !== null) 32 | .filter((_, i, arr) => !consecutiveBlank(arr, i)) 33 | .join(`${separator}\n`), 34 | prefix, 35 | ) 36 | } 37 | 38 | function consecutiveBlank(arr: Array, i: number): boolean { 39 | const prev = arr[i - 1] 40 | const curr = arr[i] 41 | 42 | if (prev === undefined || curr === undefined) { 43 | return false 44 | } 45 | 46 | return isBlank(prev) && isBlank(curr) 47 | } 48 | 49 | type NestedArray = Array | T> 50 | function flatten(xs: NestedArray): T[] { 51 | const output: T[] = [] 52 | for (const x of xs) { 53 | if (Array.isArray(x)) { 54 | output.push(...flatten(x)) 55 | } else { 56 | output.push(x) 57 | } 58 | } 59 | 60 | return output 61 | } 62 | 63 | function isBlank(value: unknown): boolean { 64 | return typeof value === 'string' ? value.trim() === '' : false 65 | } 66 | -------------------------------------------------------------------------------- /src/core/files/__tests__/__fixtures__/fileTree.ts: -------------------------------------------------------------------------------- 1 | import { directory, file } from '../../fileSystem' 2 | 3 | export const profileImage = file('profile.jpg', 'profile') 4 | 5 | export default directory('documents', [ 6 | directory('media', [ 7 | directory('images', [profileImage]), 8 | directory('music', [ 9 | directory('artists', [ 10 | directory('chico-buarque', [ 11 | file('apesar-de-voce.mp3', 'Apesar de Você'), 12 | file('pedro-pedreiro.mp3', 'pedro-pedreiro'), 13 | ]), 14 | directory('silvio-rodriguez', [file('ojala.mp3', 'ojala')]), 15 | ]), 16 | ]), 17 | directory('videos', []), 18 | ]), 19 | file('essay.docx', 'essay'), 20 | ]) 21 | -------------------------------------------------------------------------------- /src/core/framework/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import blogSchema from '@src/api/schema/examples/blog' 2 | import { defaultDbOptions } from '@src/core/database' 3 | import { directory, isDirectory } from '@src/core/files/fileSystem' 4 | import { Framework, GenerateArgs, ProjectType } from '..' 5 | 6 | const MockFramework: Framework = { 7 | name: 'mock', 8 | displayName() { 9 | return 'Mock Framework' 10 | }, 11 | generate() { 12 | return directory('mock', []) 13 | }, 14 | projectType() { 15 | return ProjectType.Npm 16 | }, 17 | defaultFile() { 18 | return undefined 19 | }, 20 | defaultModelFile() { 21 | return undefined 22 | }, 23 | modelFromPath() { 24 | return undefined 25 | }, 26 | } 27 | 28 | describe('framework', () => { 29 | it('should display a name', () => { 30 | expect(typeof MockFramework.displayName()).toEqual('string') 31 | }) 32 | it('should generate a directory', () => { 33 | const generateArgs: GenerateArgs = { schema: blogSchema, dbOptions: defaultDbOptions } 34 | expect(isDirectory(MockFramework.generate(generateArgs))).toBe(true) 35 | }) 36 | it('should have a project type', () => { 37 | expect(MockFramework.projectType()).toEqual(ProjectType.Npm) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/core/framework/index.ts: -------------------------------------------------------------------------------- 1 | import { SchemaMeta } from '@src/api/meta' 2 | import { DbOptions } from '@src/core/database' 3 | import { FileSystemItem } from '@src/core/files/fileSystem' 4 | import { Model, Schema } from '@src/core/schema' 5 | 6 | export type GenerateArgs = { 7 | schema: Schema 8 | meta?: SchemaMeta 9 | dbOptions: DbOptions 10 | } 11 | 12 | export enum ProjectType { 13 | Npm = 'NPM', 14 | } 15 | 16 | export interface Framework { 17 | name: string 18 | displayName(): string 19 | generate(args: GenerateArgs): FileSystemItem 20 | projectType(): ProjectType 21 | defaultFile?(root: FileSystemItem): string | undefined 22 | defaultModelFile(model: Model, root: FileSystemItem): string | undefined 23 | modelFromPath(path: string, schema: Schema): Model | undefined 24 | } 25 | -------------------------------------------------------------------------------- /src/core/schema/__tests__/__fixtures__/association.ts: -------------------------------------------------------------------------------- 1 | import { uniqueId } from '@src/utils/string' 2 | import { 3 | Association, 4 | AssociationTypeType, 5 | BelongsToAssociation, 6 | HasManyAssociation, 7 | HasOneAssociation, 8 | ManyToManyAssociation, 9 | ManyToManyThroughModel, 10 | ManyToManyThroughTable, 11 | ThroughType, 12 | } from '../../association' 13 | 14 | export const belongsToType_: BelongsToAssociation = { 15 | type: AssociationTypeType.BelongsTo, 16 | } 17 | export const hasManyType_: HasManyAssociation = { 18 | type: AssociationTypeType.HasMany, 19 | } 20 | export const hasOneType_: HasOneAssociation = { 21 | type: AssociationTypeType.HasOne, 22 | } 23 | export const throughTable_: ManyToManyThroughTable = { 24 | type: ThroughType.ThroughTable, 25 | table: 'foo', 26 | } 27 | 28 | export const throughModel_: ManyToManyThroughModel = { 29 | type: ThroughType.ThroughModel, 30 | modelId: uniqueId(), 31 | } 32 | 33 | export const manyToManyTableType_: ManyToManyAssociation = { 34 | type: AssociationTypeType.ManyToMany, 35 | through: throughTable_, 36 | targetFk: null, 37 | } 38 | export const manyToManyModelType_: ManyToManyAssociation = { 39 | type: AssociationTypeType.ManyToMany, 40 | through: throughModel_, 41 | targetFk: null, 42 | } 43 | 44 | export const baseAssociation: Omit = { 45 | id: uniqueId(), 46 | alias: null, 47 | foreignKey: null, 48 | sourceModelId: uniqueId(), 49 | targetModelId: uniqueId(), 50 | } 51 | 52 | export const belongsTo: Association = { 53 | ...baseAssociation, 54 | type: belongsToType_, 55 | } 56 | export const hasMany: Association = { ...baseAssociation, type: hasManyType_ } 57 | export const hasOne: Association = { ...baseAssociation, type: hasOneType_ } 58 | export const manyToManyTable: Association = { 59 | ...baseAssociation, 60 | type: manyToManyTableType_, 61 | } 62 | export const manyToManyModel: Association = { 63 | ...baseAssociation, 64 | type: manyToManyModelType_, 65 | } 66 | -------------------------------------------------------------------------------- /src/core/schema/__tests__/schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { emptyAssociation, emptyField, emptyModel } from '../schema' 2 | 3 | describe('schema schema', () => { 4 | describe('emptyModel', () => { 5 | it('return an empy model', async () => { 6 | const model = emptyModel() 7 | expect(typeof model.id).toBe('string') 8 | expect(typeof model.name).toBe('string') 9 | expect(model.fields).toEqual([]) 10 | expect(model.associations).toEqual([]) 11 | }) 12 | }) 13 | 14 | describe('emptyField', () => { 15 | it('return an empy field', async () => { 16 | const field = emptyField() 17 | expect(typeof field.id).toBe('string') 18 | expect(typeof field.name).toBe('string') 19 | }) 20 | }) 21 | 22 | describe('emptyAssociation', () => { 23 | it('return an empy association', async () => { 24 | const association = emptyAssociation('foo', 'bar') 25 | expect(typeof association.id).toBe('string') 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/core/schema/index.ts: -------------------------------------------------------------------------------- 1 | export * from './association' 2 | export * from './dataType' 3 | export * from './schema' 4 | -------------------------------------------------------------------------------- /src/core/validation/messages.ts: -------------------------------------------------------------------------------- 1 | export const NAME_REQUIRED_MESSAGE = 'required' 2 | export const NAME_WITH_NUMBER_MESSAGE = 'cannot start with a number' 3 | export const NAME_TOO_LONG_MESSAGE = 'cannot be longer than 63 characters' 4 | export const NAME_UNIQUE_MESSAGE = 'must be unique' 5 | -------------------------------------------------------------------------------- /src/frameworks/__fixtures__/schemas/fields.ts: -------------------------------------------------------------------------------- 1 | import { Model, Schema, field, model, schema, stringDataType } from '@src/core/schema' 2 | import { fromParts } from '@src/utils/dateTime' 3 | 4 | const time = fromParts(2020, 7, 1) 5 | 6 | const fields: Model = model({ 7 | name: 'category', 8 | createdAt: time, 9 | updatedAt: time, 10 | fields: [ 11 | field({ 12 | name: 'field', 13 | type: stringDataType(), 14 | }), 15 | field({ 16 | name: 'pk field', 17 | type: stringDataType(), 18 | primaryKey: true, 19 | }), 20 | field({ 21 | name: 'required field', 22 | type: stringDataType(), 23 | required: true, 24 | }), 25 | field({ 26 | name: 'optional field', 27 | type: stringDataType(), 28 | unique: true, 29 | }), 30 | ], 31 | associations: [], 32 | }) 33 | 34 | const fieldsSchema: Schema = schema({ 35 | name: 'fields', 36 | createdAt: time, 37 | updatedAt: time, 38 | models: [fields], 39 | }) 40 | 41 | export default fieldsSchema 42 | -------------------------------------------------------------------------------- /src/frameworks/__fixtures__/schemas/pks.ts: -------------------------------------------------------------------------------- 1 | import { field, integerDataType, model, Model, schema, Schema } from '@src/core/schema' 2 | import { fromParts } from '@src/utils/dateTime' 3 | 4 | const time = fromParts(2020, 4, 1) 5 | 6 | const defaultPk: Model = model({ 7 | name: 'default', 8 | createdAt: time, 9 | updatedAt: time, 10 | }) 11 | 12 | const id: Model = model({ 13 | name: 'id', 14 | createdAt: time, 15 | updatedAt: time, 16 | fields: [ 17 | field({ 18 | name: 'id', 19 | type: integerDataType(), 20 | primaryKey: true, 21 | }), 22 | ], 23 | }) 24 | 25 | const prefixed: Model = model({ 26 | name: 'prefixed', 27 | createdAt: time, 28 | updatedAt: time, 29 | fields: [ 30 | field({ 31 | name: 'prefixed_id', 32 | type: integerDataType(), 33 | primaryKey: true, 34 | }), 35 | ], 36 | }) 37 | 38 | const nonstandard: Model = model({ 39 | name: 'nonstandard', 40 | createdAt: time, 41 | updatedAt: time, 42 | fields: [ 43 | field({ 44 | name: 'other_id', 45 | type: integerDataType(), 46 | primaryKey: true, 47 | }), 48 | ], 49 | }) 50 | 51 | const fieldsSchema: Schema = schema({ 52 | name: 'fields', 53 | createdAt: time, 54 | updatedAt: time, 55 | models: [defaultPk, id, prefixed, nonstandard], 56 | }) 57 | 58 | export default fieldsSchema 59 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/__tests__/generateConfigs.spec.ts: -------------------------------------------------------------------------------- 1 | import sakila from '@src/api/schema/examples/sakila' 2 | import { DbCaseStyle, DbNounForm, DbOptions, defaultDbOptions } from '@src/core/database' 3 | import { FileSystemItem } from '@src/core/files/fileSystem' 4 | import { printFileSystemItem } from '@src/frameworks/testUtils' 5 | import { kebabCase } from '@src/utils/string' 6 | import { addSerializer } from 'jest-specific-snapshot' 7 | import { SequelizeFramework } from '..' 8 | 9 | addSerializer({ 10 | test() { 11 | return true 12 | }, 13 | print(item: FileSystemItem) { 14 | return printFileSystemItem(item, ['sakila/db.ts', 'sakila/models/Film.ts']) 15 | }, 16 | }) 17 | 18 | const snakePlural: DbOptions = { 19 | ...defaultDbOptions, 20 | caseStyle: DbCaseStyle.Snake, 21 | nounForm: DbNounForm.Plural, 22 | } 23 | 24 | const snakeSingular: DbOptions = { 25 | ...defaultDbOptions, 26 | caseStyle: DbCaseStyle.Snake, 27 | nounForm: DbNounForm.Singular, 28 | } 29 | 30 | const camelPlural: DbOptions = { 31 | ...defaultDbOptions, 32 | caseStyle: DbCaseStyle.Camel, 33 | nounForm: DbNounForm.Plural, 34 | } 35 | 36 | const camelSingular: DbOptions = { 37 | ...defaultDbOptions, 38 | caseStyle: DbCaseStyle.Camel, 39 | nounForm: DbNounForm.Singular, 40 | } 41 | 42 | const noTimestamps: DbOptions = { 43 | ...defaultDbOptions, 44 | timestamps: false, 45 | } 46 | 47 | const noPrefixPks: DbOptions = { 48 | ...defaultDbOptions, 49 | prefixPks: false, 50 | } 51 | 52 | describe('Sequelize Framework', () => { 53 | const cases: Record = { 54 | snakePlural, 55 | snakeSingular, 56 | camelPlural, 57 | camelSingular, 58 | noTimestamps, 59 | noPrefixPks, 60 | } 61 | 62 | it.each(Object.entries(cases))('generates the correct code for %s', (key, dbOptions) => { 63 | const code = SequelizeFramework.generate({ schema: sakila, dbOptions }) 64 | expect(code).toMatchSpecificSnapshot(`./__snapshots__/config-${kebabCase(key)}.shot`) 65 | }) 66 | 67 | const paranoidCases: Record = { 68 | snakePlural, 69 | noTimestamps, 70 | } 71 | 72 | const paranoidSchema = { 73 | ...sakila, 74 | models: sakila.models.map((model) => ({ ...model, softDelete: true })), 75 | } 76 | 77 | it.each(Object.entries(paranoidCases))( 78 | 'generates the correct code for paranoid models with %s', 79 | (key, dbOptions) => { 80 | const code = SequelizeFramework.generate({ schema: paranoidSchema, dbOptions }) 81 | expect(code).toMatchSpecificSnapshot(`./__snapshots__/config-paranoid-${kebabCase(key)}.shot`) 82 | }, 83 | ) 84 | }) 85 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/__tests__/generateDialects.spec.ts: -------------------------------------------------------------------------------- 1 | import sakila from '@src/api/schema/examples/sakila' 2 | import { defaultDbOptions, SqlDialect } from '@src/core/database' 3 | import { FileSystemItem } from '@src/core/files/fileSystem' 4 | import { printFileSystemItem } from '@src/frameworks/testUtils' 5 | import { kebabCase } from '@src/utils/string' 6 | import { addSerializer } from 'jest-specific-snapshot' 7 | import { SequelizeFramework } from '..' 8 | 9 | addSerializer({ 10 | test() { 11 | return true 12 | }, 13 | print(item: FileSystemItem) { 14 | return printFileSystemItem(item, [ 15 | 'sakila/db.ts', 16 | 'sakila/package.json', 17 | 'sakila/config/config.js', 18 | 'sakila/models/Film.ts', 19 | ]) 20 | }, 21 | }) 22 | 23 | describe('Sequelize Framework', () => { 24 | it.each(Object.values(SqlDialect))('generates the correct code for %s', (sqlDialect) => { 25 | const code = SequelizeFramework.generate({ 26 | schema: sakila, 27 | dbOptions: { ...defaultDbOptions, sqlDialect }, 28 | }) 29 | expect(code).toMatchSpecificSnapshot(`./__snapshots__/dialect-${kebabCase(sqlDialect)}.shot`) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/__tests__/generateSchemas.spec.ts: -------------------------------------------------------------------------------- 1 | import blog from '@src/api/schema/examples/blog' 2 | import employees from '@src/api/schema/examples/employees' 3 | import sakila from '@src/api/schema/examples/sakila' 4 | import { defaultDbOptions } from '@src/core/database' 5 | import { FileSystemItem } from '@src/core/files/fileSystem' 6 | import { Schema } from '@src/core/schema' 7 | import { printFileSystemItem } from '@src/frameworks/testUtils' 8 | import { kebabCase } from '@src/utils/string' 9 | import { addSerializer } from 'jest-specific-snapshot' 10 | import { SequelizeFramework } from '..' 11 | 12 | addSerializer({ 13 | test() { 14 | return true 15 | }, 16 | print(item: FileSystemItem) { 17 | return printFileSystemItem(item) 18 | }, 19 | }) 20 | 21 | describe('Sequelize Framework', () => { 22 | const cases: Record = { 23 | blog, 24 | employees, 25 | sakila, 26 | } 27 | it.each(Object.entries(cases))('generates the correct code for %s', (key, schema) => { 28 | const code = SequelizeFramework.generate({ schema, dbOptions: defaultDbOptions }) 29 | expect(code).toMatchSpecificSnapshot(`./__snapshots__/schema-${kebabCase(key)}.shot`) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { ProjectType } from '@src/core/framework' 2 | import { SequelizeFramework } from '..' 3 | 4 | describe('Sequelize Framework', () => { 5 | it('returns the correct name', () => { 6 | expect(SequelizeFramework.displayName()).toBe('Sequelize') 7 | }) 8 | 9 | it('returns the correct project type', () => { 10 | expect(SequelizeFramework.projectType()).toBe(ProjectType.Npm) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/index.ts: -------------------------------------------------------------------------------- 1 | import { DirectoryItem, isDirectory, pathFilename } from '@src/core/files/fileSystem' 2 | import { Framework, ProjectType } from '@src/core/framework' 3 | import { Model, Schema } from '@src/core/schema' 4 | import { generateSequelizeProject } from './generate' 5 | import { modelFileName } from './utils/model' 6 | 7 | export const SequelizeFramework: Framework = { 8 | name: 'sequelize', 9 | displayName: (): string => 'Sequelize', 10 | projectType: (): ProjectType => ProjectType.Npm, 11 | generate: generateSequelizeProject, 12 | defaultFile: (root: DirectoryItem): string => { 13 | return `${root.name}/models/index.ts` 14 | }, 15 | defaultModelFile: (model: Model, root: DirectoryItem): string | undefined => { 16 | const modelFile = root.files 17 | .filter(isDirectory) 18 | .find((item) => item.name === 'models') 19 | ?.files?.find((file) => file.name === modelFileName(model)) 20 | 21 | return modelFile && `${root.name}/models/${modelFile.name}` 22 | }, 23 | modelFromPath: (path: string, schema: Schema): Model | undefined => { 24 | const filename = pathFilename(path) 25 | return schema.models.find((m) => modelFileName(m) === filename) 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/templates/db.ts: -------------------------------------------------------------------------------- 1 | import { blank, lines } from '@src/core/codegen' 2 | import { DbCaseStyle, DbNounForm, DbOptions } from '@src/core/database' 3 | 4 | export type DbTemplateArgs = { 5 | dbOptions: DbOptions 6 | } 7 | 8 | export function dbTemplate({ dbOptions }: DbTemplateArgs): string { 9 | return lines([ 10 | imports(), 11 | blank(), 12 | instanceDeclaration({ dbOptions }), 13 | blank(), 14 | exportInstance(), 15 | blank(), 16 | ]) 17 | } 18 | 19 | const imports = (): string => 20 | lines([ 21 | `import { Sequelize, Options } from 'sequelize'`, 22 | `import configs from './config/config.js'`, 23 | blank(), 24 | `const env = process.env.NODE_ENV || 'development'`, 25 | `const config = (configs as {[key: string]: Options})[env]`, 26 | ]) 27 | 28 | const instanceDeclaration = ({ dbOptions }: DbTemplateArgs) => 29 | lines([ 30 | `const db: Sequelize = new Sequelize({`, 31 | lines(['...config', defineField(dbOptions)], { separator: ',', depth: 2 }), 32 | '})', 33 | ]) 34 | 35 | const exportInstance = (): string => 'export default db' 36 | 37 | const hasOptions = (dbOptions: DbOptions): boolean => 38 | !dbOptions.timestamps || 39 | dbOptions.caseStyle === DbCaseStyle.Snake || 40 | dbOptions.nounForm === DbNounForm.Singular 41 | 42 | const defineField = (dbOptions: DbOptions): string | null => 43 | !hasOptions(dbOptions) 44 | ? null 45 | : lines([ 46 | `define: {`, 47 | lines( 48 | [ 49 | freezeTableNameField(dbOptions), 50 | underscoredField(dbOptions.caseStyle === DbCaseStyle.Snake), 51 | timestampsField(dbOptions.timestamps), 52 | ], 53 | { separator: ',', depth: 2 }, 54 | ), 55 | '}', 56 | ]) 57 | 58 | const underscoredField = (underscored: boolean): string | null => 59 | underscored ? 'underscored: true' : null 60 | 61 | const timestampsField = (timestamps: boolean): string | null => 62 | timestamps ? null : 'timestamps: false' 63 | 64 | const freezeTableNameField = ({ caseStyle, nounForm }: DbOptions): string | null => 65 | caseStyle === DbCaseStyle.Camel && nounForm === DbNounForm.Singular 66 | ? `freezeTableName: true` 67 | : null 68 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/templates/gitignore.ts: -------------------------------------------------------------------------------- 1 | import { blank, lines } from '@src/core/codegen' 2 | 3 | export function gitignoreTemplate(): string { 4 | return lines([ 5 | 'node_modules/', 6 | 'dist', 7 | 'npm-debug.log*', 8 | 'yarn-debug.log*', 9 | 'yarn-error.log*', 10 | '.npm', 11 | '.tmp', 12 | blank(), 13 | ]) 14 | } 15 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/templates/migrations/createModel.ts: -------------------------------------------------------------------------------- 1 | import { blank, indent, lines } from '@src/core/codegen' 2 | import { DbOptions } from '@src/core/database' 3 | import { Model } from '@src/core/schema' 4 | import { fieldTemplate } from '../../utils/field' 5 | import { dbTableName } from '../../utils/migrations' 6 | 7 | type MigrationCreateFileNameArgs = { 8 | model: Model 9 | dbOptions: DbOptions 10 | timestamp: number 11 | } 12 | 13 | export function migrationCreateFilename({ 14 | model, 15 | dbOptions, 16 | timestamp, 17 | }: MigrationCreateFileNameArgs): string { 18 | return `${timestamp}-create-${dbTableName({ model, dbOptions })}.js` 19 | } 20 | 21 | type CreateModelMigrationArgs = { 22 | model: Model 23 | dbOptions: DbOptions 24 | } 25 | 26 | export function createModelMigration({ model, dbOptions }: CreateModelMigrationArgs): string { 27 | return lines([ 28 | // TODO refactor type defs to use either Sequelize or DataTypes 29 | `const DataTypes = require('sequelize').DataTypes`, 30 | blank(), 31 | `module.exports = {`, 32 | up({ model, dbOptions }), 33 | down({ model, dbOptions }), 34 | `};`, 35 | ]) 36 | } 37 | 38 | function up({ model, dbOptions }: CreateModelMigrationArgs): string { 39 | const tableName = dbTableName({ model, dbOptions }) 40 | return lines( 41 | [ 42 | `up: async (queryInterface, Sequelize) => {`, 43 | lines( 44 | [ 45 | `await queryInterface.createTable('${tableName}', {`, 46 | lines( 47 | model.fields.map((field) => 48 | fieldTemplate({ field, dbOptions, define: true, migration: true }), 49 | ), 50 | { depth: 2, separator: ',' }, 51 | ), 52 | `})`, 53 | ], 54 | { depth: 2 }, 55 | ), 56 | `},`, 57 | ], 58 | { depth: 2 }, 59 | ) 60 | } 61 | 62 | function down({ model, dbOptions }: CreateModelMigrationArgs): string { 63 | const tableName = dbTableName({ model, dbOptions }) 64 | return lines( 65 | [ 66 | `down: async (queryInterface, Sequelize) => {`, 67 | indent(2, `await queryInterface.dropTable('${tableName}');`), 68 | `},`, 69 | ], 70 | { depth: 2 }, 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/templates/model/index.ts: -------------------------------------------------------------------------------- 1 | import { blank, lines } from '@src/core/codegen' 2 | import { DbOptions } from '@src/core/database' 3 | import { Association, Model, Schema } from '@src/core/schema' 4 | import { ModelAssociation } from '../../utils/model' 5 | import { modelImportsTemplate } from './imports' 6 | import { modelClassTemplate } from './modelClass' 7 | 8 | export type ModelTemplateArgs = { 9 | model: Model 10 | schema: Schema 11 | dbOptions: DbOptions 12 | } 13 | 14 | export function modelTemplate({ model, schema, dbOptions }: ModelTemplateArgs): string { 15 | const associations = joinModelAssociations({ 16 | associations: model.associations, 17 | models: schema.models, 18 | }) 19 | 20 | return modelTemplate_({ model, associations, dbOptions }) 21 | } 22 | 23 | type JoinModelAssociationsArgs = { 24 | associations: Association[] 25 | models: Model[] 26 | } 27 | type ModelById = { [id: string]: Model } 28 | function joinModelAssociations({ 29 | associations, 30 | models, 31 | }: JoinModelAssociationsArgs): ModelAssociation[] { 32 | const modelById: ModelById = models.reduce((acc, model) => { 33 | acc[model.id] = model 34 | return acc 35 | }, {}) 36 | 37 | return associations.map((association) => { 38 | return { association, model: modelById[association.targetModelId] } 39 | }) 40 | } 41 | 42 | type ModelTemplateArgs_ = { 43 | model: Model 44 | associations: ModelAssociation[] 45 | dbOptions: DbOptions 46 | } 47 | function modelTemplate_({ model, associations, dbOptions }: ModelTemplateArgs_): string { 48 | return lines([ 49 | modelImportsTemplate({ model, associations, dbOptions }), 50 | blank(), 51 | modelClassTemplate({ model, associations, dbOptions }), 52 | blank(), 53 | ]) 54 | } 55 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/templates/readme.ts: -------------------------------------------------------------------------------- 1 | import { SchemaMeta } from '@src/api/meta' 2 | import { blank, lines } from '@src/core/codegen' 3 | import { DbOptions, SqlDialect } from '@src/core/database' 4 | import { Schema } from '@src/core/schema' 5 | import { intersperse } from '@src/utils/array' 6 | 7 | export type ReadmeTemplateArgs = { 8 | schema: Schema 9 | meta?: SchemaMeta | null 10 | dbOptions: DbOptions 11 | } 12 | 13 | export function readmeTemplate({ schema, meta, dbOptions }: ReadmeTemplateArgs): string { 14 | return lines([ 15 | `# ${schema.name}`, 16 | 'This project was generated with [Sequelize UI](https://github.com/tomjschuster/sequelize-ui). The project is a simple [Node.js](https://nodejs.dev/) server with [Sequelize ORM](https://sequelize.org/).', 17 | blank(), 18 | 'Be sure to test all code for correctness and to test database migrations in a test environment before deploying to production.', 19 | blank(), 20 | meta ? schemaDescription(meta) : null, 21 | '## Running Project', 22 | blank(), 23 | '### Prerequesites', 24 | '- [Node.js](https://nodejs.dev/)', 25 | `- ${dbDependency(dbOptions.sqlDialect)}`, 26 | blank(), 27 | '### Setup', 28 | '1. Install dependencies: `npm install`', 29 | '2. Setup database: `npm run db:up`', 30 | blank(), 31 | '### Run', 32 | '- Local development: `npm run dev`', 33 | '- Production build: `npm run build && npm start`', 34 | blank(), 35 | '## Bug Reports', 36 | 'Please report any bugs with generated code at [Sequelize UI Issues](https://github.com/tomjschuster/sequelize-ui/issues).', 37 | blank(), 38 | ]) 39 | } 40 | 41 | function dbDependency(dialect: SqlDialect): string { 42 | switch (dialect) { 43 | case SqlDialect.MariaDb: 44 | return '[MariaDB](https://mariadb.com/)' 45 | case SqlDialect.MsSql: 46 | return '[SQL Server](https://www.microsoft.com/en-us/sql-server/sql-server-2019)' 47 | case SqlDialect.MySql: 48 | return '[MariaDB](https://www.mysql.com/)' 49 | case SqlDialect.Postgres: 50 | return '[PostgreSQL](https://www.postgresql.org/)' 51 | case SqlDialect.Sqlite: 52 | return '[SQLite](https://www.sqlite.org/index.html)' 53 | } 54 | } 55 | 56 | function schemaDescription({ displayName, description }: SchemaMeta): string | null { 57 | return lines([ 58 | '## Schema', 59 | `The Sequelize schema for this project was derived from the ${displayName} sample database.`, 60 | blank(), 61 | ...intersperse(description, blank()), 62 | blank(), 63 | ]) 64 | } 65 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/templates/server.ts: -------------------------------------------------------------------------------- 1 | import { blank, lines } from '@src/core/codegen' 2 | import { DbOptions } from '@src/core/database' 3 | 4 | type ServerTemplateArgs = { 5 | dbOptions: DbOptions 6 | } 7 | export const serverTemplate = ({ dbOptions }: ServerTemplateArgs): string => 8 | lines([ 9 | `import http from 'http'`, 10 | `import db from './db'`, 11 | `import { initModels } from './models'`, 12 | blank(), 13 | defineRun({ dbOptions }), 14 | blank(), 15 | 'run()', 16 | ]) 17 | 18 | function defineRun({ dbOptions }: ServerTemplateArgs): string { 19 | return lines([ 20 | 'async function run() {', 21 | lines( 22 | [ 23 | `initModels(db)`, 24 | dbOptions.migrations ? null : `await db.sync()`, 25 | `const hostname = process.env.HOSTNAME || '127.0.0.1'`, 26 | `const port = parseInt(process.env.PORT || '3000')`, 27 | blank(), 28 | `const server = http.createServer((req, res) => {`, 29 | lines( 30 | [ 31 | `res.statusCode = 200`, 32 | `res.setHeader('Content-Type', 'text/plain')`, 33 | `res.end('Hello World')`, 34 | ], 35 | { depth: 2 }, 36 | ), 37 | `})`, 38 | blank(), 39 | `server.listen(port, hostname, () => {`, 40 | lines([`console.log(\`Server running at http://\${hostname}:\${port}/\`)`], { depth: 2 }), 41 | `})`, 42 | ], 43 | { depth: 2 }, 44 | ), 45 | '}', 46 | ]) 47 | } 48 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/templates/tsconfig.ts: -------------------------------------------------------------------------------- 1 | export function tsconfigTemplate(): string { 2 | return `{ 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "module": "commonjs", 6 | "lib": ["ES2020"], 7 | "outDir": "dist", 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "esModuleInterop": true, 11 | "allowJs": true, 12 | "forceConsistentCasingInFileNames": true 13 | } 14 | } 15 | ` 16 | } 17 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/templates/types.ts: -------------------------------------------------------------------------------- 1 | import { blank, lines } from '@src/core/codegen' 2 | 3 | export function typesTemplate(): string { 4 | return lines([jsonType()]) 5 | } 6 | 7 | function jsonType(): string { 8 | return lines([ 9 | `export interface JsonMap {[member: string]: string | number | boolean | null | JsonArray | JsonMap }`, 10 | blank(), 11 | `export interface JsonArray extends Array {}`, 12 | blank(), 13 | `export type Json = JsonMap | JsonArray | string | number | boolean | null`, 14 | blank(), 15 | ]) 16 | } 17 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/utils/associations.ts: -------------------------------------------------------------------------------- 1 | import { caseByDbCaseStyle, DbOptions } from '@src/core/database' 2 | import { 3 | Association, 4 | associationTypeIsSingular, 5 | AssociationTypeType, 6 | Model, 7 | } from '@src/core/schema' 8 | import { camelCase, plural, singular } from '@src/utils/string' 9 | import { modelName } from './model' 10 | 11 | type AssociationNameArgs = { 12 | association: Association 13 | targetModel: Model 14 | } 15 | export function associationName({ association, targetModel }: AssociationNameArgs): string { 16 | const name = association.alias ? association.alias : modelName(targetModel) 17 | return associationTypeIsSingular(association.type) 18 | ? camelCase(singular(name)) 19 | : camelCase(plural(name)) 20 | } 21 | 22 | type ModelById = Map 23 | 24 | type GetForeignKeyArgs = { 25 | model: Model 26 | association: Association 27 | modelById: ModelById 28 | dbOptions: DbOptions 29 | } 30 | export function getForeignKey({ 31 | model, 32 | association, 33 | modelById, 34 | dbOptions, 35 | }: GetForeignKeyArgs): string { 36 | if (association.foreignKey) return caseByDbCaseStyle(association.foreignKey, dbOptions.caseStyle) 37 | const target = modelById.get(association.targetModelId) 38 | const name = 39 | association.alias && association.type.type === AssociationTypeType.BelongsTo 40 | ? association.alias 41 | : association.type.type === AssociationTypeType.BelongsTo && target 42 | ? target.name 43 | : model.name 44 | 45 | return caseByDbCaseStyle(`${name} id`, dbOptions.caseStyle) 46 | } 47 | 48 | type GetOtherKeyArgs = { 49 | association: Association 50 | modelById: ModelById 51 | dbOptions: DbOptions 52 | } 53 | export function getOtherKey({ association, modelById, dbOptions }: GetOtherKeyArgs): string | null { 54 | const target = modelById.get(association.targetModelId) 55 | if (!target || association.type.type !== AssociationTypeType.ManyToMany) return null 56 | if (association.type.targetFk) { 57 | return caseByDbCaseStyle(association.type.targetFk, dbOptions.caseStyle) 58 | } 59 | 60 | const name = association.alias ? association.alias : target.name 61 | 62 | return caseByDbCaseStyle(`${name} id`, dbOptions.caseStyle) 63 | } 64 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { SqlDialect } from '@src/core/database' 2 | 3 | export function sqlDialiectConfigValue(dialect: SqlDialect): string { 4 | switch (dialect) { 5 | case SqlDialect.MariaDb: 6 | return 'mariadb' 7 | case SqlDialect.MsSql: 8 | return 'mssql' 9 | case SqlDialect.MySql: 10 | return 'mysql' 11 | case SqlDialect.Postgres: 12 | return 'postgres' 13 | case SqlDialect.Sqlite: 14 | return 'sqlite' 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/utils/migrations.ts: -------------------------------------------------------------------------------- 1 | import { DbOptions, nounFormByDbNounForm, tableCaseByDbCaseStyle } from '@src/core/database' 2 | import { Model } from '@src/core/schema' 3 | 4 | type DbTableNameArgs = { 5 | model: Model 6 | dbOptions: DbOptions 7 | } 8 | export function dbTableName({ model, dbOptions }: DbTableNameArgs): string { 9 | const casedName = tableCaseByDbCaseStyle(model.name, dbOptions.caseStyle) 10 | return nounFormByDbNounForm(casedName, dbOptions.nounForm) 11 | } 12 | -------------------------------------------------------------------------------- /src/frameworks/sequelize/utils/model.ts: -------------------------------------------------------------------------------- 1 | import { Association, Model } from '@src/core/schema' 2 | import { pascalCase, singular } from '@src/utils/string' 3 | 4 | export type ModelAssociation = { 5 | model: Model 6 | association: Association 7 | } 8 | 9 | export function modelName({ name }: Model): string { 10 | return singular(pascalCase(name)) 11 | } 12 | 13 | export function modelFileName(model: Model): string { 14 | return `${modelName(model)}.ts` 15 | } 16 | -------------------------------------------------------------------------------- /src/frameworks/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { FileItem, FileSystemItem, isFile, withPathsBreadthFirst } from '@src/core/files/fileSystem' 2 | 3 | function jsDoc(value: string): string { 4 | const leftPad = Math.max(0, 38 - Math.ceil(value.length / 2)) 5 | const rightPad = Math.max(0, 38 - Math.floor(value.length / 2)) 6 | return [ 7 | `/${'*'.repeat(78)}`, 8 | ` *${' '.repeat(leftPad)}${value}${' '.repeat(rightPad)}*`, 9 | ` ${'*'.repeat(78)}/`, 10 | ].join('\n') 11 | } 12 | 13 | export function printFileSystemItem(item: FileSystemItem, onlyPaths?: string[]): string { 14 | return withPathsBreadthFirst(item) 15 | .filter( 16 | (value): value is [string, FileItem] => 17 | isFile(value[1]) && (!onlyPaths || onlyPaths.includes(value[0])), 18 | ) 19 | .map(([path, file]) => `\n${jsDoc('/' + path)}\n\n${file.content}\n`) 20 | .join('') 21 | } 22 | -------------------------------------------------------------------------------- /src/io/__tests__/copy.spec.ts: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | import { file } from '@src/core/files/fileSystem' 3 | import copy from 'copy-to-clipboard' 4 | import { copyFile } from '../copy' 5 | 6 | jest.mock('copy-to-clipboard', () => jest.fn()) 7 | 8 | describe('io', () => { 9 | describe('copyFile', () => { 10 | it('should call copy with the file contents', async () => { 11 | jest.spyOn(window, 'prompt').mockImplementation() 12 | copyFile(file('foo title', 'foo contents')) 13 | expect(copy).toHaveBeenCalledWith('foo contents') 14 | }) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/io/__tests__/download.spec.ts: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | import { directory, file } from '@src/core/files/fileSystem' 3 | import { saveAs } from 'file-saver' 4 | import Zip from 'jszip' 5 | import { download, FAILED_TO_CREATE_FOLDER_ERROR } from '../download' 6 | 7 | jest.mock('file-saver', () => ({ saveAs: jest.fn() })) 8 | 9 | const mockBlob = new Blob(['foo blob']) 10 | 11 | const mockZip = { 12 | folder: jest.fn(() => ({ file: jest.fn(), generateAsync: () => Promise.resolve(mockBlob) })), 13 | } 14 | 15 | jest.mock('jszip', () => jest.fn(() => mockZip)) 16 | 17 | describe('io', () => { 18 | describe('download', () => { 19 | it('should call saveAs with the file name and contents', async () => { 20 | await download(file('foo title', 'foo contents')) 21 | expect(saveAs).toHaveBeenCalledWith('foo contents', 'foo title') 22 | }) 23 | 24 | it('should return a rejected promise when saveAs fails', () => { 25 | // @ts-expect-error only using partial overlap 26 | ;(saveAs as jest.Mock).mockImplementationOnce(() => { 27 | throw new Error('foo') 28 | }) 29 | 30 | download(file('foo title', 'foo contents')).catch((e) => { 31 | expect(e).toEqual(new Error('foo')) 32 | }) 33 | }) 34 | 35 | it('should generate a zip file asynchronously', async () => { 36 | await download(directory('foo dir', [file('foo title', 'foo contents')])) 37 | expect(saveAs).toHaveBeenCalledWith(mockBlob, 'foo dir') 38 | }) 39 | 40 | it('should return a rejected promise when folder create fails', async () => { 41 | // @ts-expect-error only using partial overlap 42 | ;(Zip as jest.Mock).mockReturnValueOnce({ folder: jest.fn() }) 43 | 44 | download(directory('foo', [file('foo title', 'foo contents')])).catch((e) => { 45 | expect(e).toEqual(new Error(FAILED_TO_CREATE_FOLDER_ERROR)) 46 | }) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/io/copy.ts: -------------------------------------------------------------------------------- 1 | import { FileItem } from '@src/core/files/fileSystem' 2 | import copy from 'copy-to-clipboard' 3 | 4 | export function copyFile(file: FileItem): void { 5 | copy(file.content) 6 | } 7 | -------------------------------------------------------------------------------- /src/io/download.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DirectoryItem, 3 | FileItem, 4 | FileSystemItem, 5 | isDirectory, 6 | isFile, 7 | } from '@src/core/files/fileSystem' 8 | import { saveAs } from 'file-saver' 9 | import Zip from 'jszip' 10 | 11 | export const FAILED_TO_CREATE_FOLDER_ERROR = '[Zip Error] Failed to create folder' 12 | 13 | export function download(item: FileSystemItem): Promise { 14 | return isFile(item) ? downloadFile(item) : downloadDirectory(item) 15 | } 16 | 17 | function downloadDirectory(dir: DirectoryItem): Promise { 18 | return zip(dir).then((z) => 19 | z.generateAsync({ type: 'blob' }).then((blob: Blob) => saveAs(blob, dir.name)), 20 | ) 21 | } 22 | 23 | function downloadFile(f: FileItem): Promise { 24 | return new Promise((resolve, reject) => { 25 | try { 26 | saveAs(f.content, f.name) 27 | resolve() 28 | } catch (e) { 29 | reject(e) 30 | } 31 | }) 32 | } 33 | 34 | function zip(item: FileSystemItem): Promise { 35 | return zipItem(new Zip(), item) 36 | } 37 | 38 | function zipItem(z: Zip, item: FileSystemItem): Promise { 39 | return isDirectory(item) ? zipDirectory(z, item) : Promise.resolve(zipFile(z, item)) 40 | } 41 | 42 | function zipFile(z: Zip, f: FileItem): Zip { 43 | return z.file(f.name, f.content) 44 | } 45 | 46 | function zipDirectory(z: Zip, dir: DirectoryItem): Promise { 47 | const folder = z.folder(dir.name) 48 | if (!folder) return Promise.reject(Error(FAILED_TO_CREATE_FOLDER_ERROR)) 49 | 50 | dir.files.forEach((f) => zipItem(folder, f)) 51 | return Promise.resolve(folder) 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import withLayout from '@src/ui/hocs/withLayout' 2 | import { 3 | borderColor, 4 | borderWidth, 5 | classnames, 6 | display, 7 | flex, 8 | fontSize, 9 | fontWeight, 10 | padding, 11 | verticalAlign, 12 | } from '@src/ui/styles/classnames' 13 | import { backgroundWhite, flexCenterColumn, fontColor } from '@src/ui/styles/utils' 14 | import React from 'react' 15 | 16 | function NotFound(): React.ReactElement { 17 | return ( 18 |
19 |
20 |

31 | 404 32 |

33 | 34 |
41 |

This page could not be found.

42 |
43 |
44 |
45 | ) 46 | } 47 | 48 | export default withLayout(() => ({ 49 | title: 'Sequelize UI | Not Found', 50 | }))(NotFound) 51 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@src/theme/globals.css' 2 | import ErrorBoundary from '@src/ui/components/ErrorBoundary' 3 | import { renderWithLayout } from '@src/ui/hocs/withLayout' 4 | import { AlertProvider } from '@src/ui/lib/alert' 5 | import { FocusProvider } from '@src/ui/lib/focus' 6 | import type { AppProps } from 'next/app' 7 | import React from 'react' 8 | 9 | function App({ Component, pageProps }: AppProps): React.ReactElement { 10 | return ( 11 | 12 | 13 | {renderWithLayout(Component, pageProps)} 14 | 15 | 16 | ) 17 | } 18 | 19 | export default App 20 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import { MODAL_PORTAL_ID } from '@src/ui/components/Modal' 3 | import { syncDomDarkModeScriptSource } from '@src/ui/utils/darkMode' 4 | import Document, { 5 | DocumentContext, 6 | DocumentInitialProps, 7 | Head, 8 | Html, 9 | Main, 10 | NextScript, 11 | } from 'next/document' 12 | 13 | class SequelizeUiDocument extends Document { 14 | static async getInitialProps(ctx: DocumentContext): Promise { 15 | const initialProps = await Document.getInitialProps(ctx) 16 | 17 | return initialProps 18 | } 19 | 20 | render(): React.ReactElement { 21 | return ( 22 | 23 | 24 |