From a4bd6b52d535a5c1a0a0b94ad8acee32f512e9b6 Mon Sep 17 00:00:00 2001 From: Tom Gobich Date: Thu, 6 Jun 2024 21:42:20 -0400 Subject: [PATCH] initial commit --- .editorconfig | 22 +++++++ .github/lock.yml | 26 ++++++++ .github/stale.yml | 24 ++++++++ .github/workflows/test.yml | 60 ++++++++++++++++++ .gitignore | 14 +++++ .npmrc | 1 + .prettierignore | 4 ++ LICENSE.md | 9 +++ README.md | 115 +++++++++++++++++++++++++++++++++++ bin/test.ts | 11 ++++ commands/make_dto.ts | 47 ++++++++++++++ configure.ts | 23 +++++++ index.ts | 10 +++ package.json | 87 ++++++++++++++++++++++++++ providers/README.md | 5 ++ services/dto_service.ts | 97 +++++++++++++++++++++++++++++ services/file_service.ts | 19 ++++++ services/import_service.ts | 56 +++++++++++++++++ services/model_service.ts | 98 +++++++++++++++++++++++++++++ src/README.md | 3 + stubs/README.md | 6 ++ stubs/main.ts | 8 +++ stubs/make/dto/main.stub | 21 +++++++ stubs/make/dto/plain.stub | 6 ++ test-helpers/models/user.txt | 38 ++++++++++++ tests/example.spec.ts | 7 +++ tsconfig.json | 7 +++ tsnode.esm.js | 18 ++++++ 28 files changed, 842 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/lock.yml create mode 100644 .github/stale.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 bin/test.ts create mode 100644 commands/make_dto.ts create mode 100644 configure.ts create mode 100644 index.ts create mode 100644 package.json create mode 100644 providers/README.md create mode 100644 services/dto_service.ts create mode 100644 services/file_service.ts create mode 100644 services/import_service.ts create mode 100644 services/model_service.ts create mode 100644 src/README.md create mode 100644 stubs/README.md create mode 100644 stubs/main.ts create mode 100644 stubs/make/dto/main.stub create mode 100644 stubs/make/dto/plain.stub create mode 100644 test-helpers/models/user.txt create mode 100644 tests/example.spec.ts create mode 100644 tsconfig.json create mode 100644 tsnode.esm.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1dfdf29 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# http://editorconfig.org + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.json] +insert_final_newline = ignore + +[**.min.js] +indent_style = ignore +insert_final_newline = ignore + +[MakeFile] +indent_style = space + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/lock.yml b/.github/lock.yml new file mode 100644 index 0000000..ea7cf67 --- /dev/null +++ b/.github/lock.yml @@ -0,0 +1,26 @@ +--- +ignoreUnless: {{ STALE_BOT }} +--- +# Configuration for Lock Threads - https://github.com/dessant/lock-threads-app + +# Number of days of inactivity before a closed issue or pull request is locked +daysUntilLock: 60 + +# Skip issues and pull requests created before a given timestamp. Timestamp must +# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable +skipCreatedBefore: false + +# Issues and pull requests with these labels will be ignored. Set to `[]` to disable +exemptLabels: ['Type: Security'] + +# Label to add before locking, such as `outdated`. Set to `false` to disable +lockLabel: false + +# Comment to post before locking. Set to `false` to disable +lockComment: > + This thread has been automatically locked since there has not been + any recent activity after it was closed. Please open a new issue for + related bugs. + +# Assign `resolved` as the reason for locking. Set to `false` to disable +setLockReason: false diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..d21cf6c --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,24 @@ +--- +ignoreUnless: {{ STALE_BOT }} +--- +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 + +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 + +# Issues with these labels will never be considered stale +exemptLabels: + - 'Type: Security' + +# Label to use when marking an issue as stale +staleLabel: 'Status: Abandoned' + +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. + +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6e82cdd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,60 @@ +name: test + +on: + - push + - pull_request + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install + run: npm install + - name: Run lint + run: npm run lint + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install + run: npm install + - name: Run typecheck + run: npm run typecheck + + tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + node-version: + - 20.10.0 + - 21.x + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install + run: npm install + - name: Run tests + run: npm test + windows: + runs-on: windows-latest + strategy: + matrix: + node-version: + - 20.10.0 + - 21.x + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install + run: npm install + - name: Run tests + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..966cd35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules +coverage +.DS_STORE +.nyc_output +.idea +.vscode/ +*.sublime-project +*.sublime-workspace +*.log +build +dist +yarn.lock +shrinkwrap.yaml +package-lock.json diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..da1f07a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +build +docs +coverage +*.html diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1bb5ef3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +# The MIT License + +Copyright (c) 2023 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..30356f7 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# AdonisJS package starter kit + +> A boilerplate for creating AdonisJS packages + +This repo provides you with a starting point for creating AdonisJS packages. Of course, you can create a package from scratch with your folder structure and workflow. However, using this starter kit can speed up the process, as you have fewer decisions to make. + +## Setup + +- Clone the repo on your computer, or use `giget` to download this repo without the Git history. + ```sh + npx giget@latest gh:adonisjs/pkg-starter-kit + ``` +- Install dependencies. +- Update the `package.json` file and define the `name`, `description`, `keywords`, and `author` properties. +- The repo is configured with an MIT license. Feel free to change that if you are not publishing under the MIT license. + +## Folder structure + +The starter kit mimics the folder structure of the official packages. Feel free to rename files and folders as per your requirements. + +``` +├── providers +├── src +├── bin +├── stubs +├── configure.ts +├── index.ts +├── LICENSE.md +├── package.json +├── README.md +├── tsconfig.json +├── tsnode.esm.js +``` + +- The `configure.ts` file exports the `configure` hook to configure the package using the `node ace configure` command. +- The `index.ts` file is the main entry point of the package. +- The `tsnode.esm.js` file runs TypeScript code using TS-Node + SWC. Please read the code comment in this file to learn more. +- The `bin` directory contains the entry point file to run Japa tests. +- Learn more about [the `providers` directory](./providers/README.md). +- Learn more about [the `src` directory](./src/README.md). +- Learn more about [the `stubs` directory](./stubs/README.md). + +### File system naming convention + +We use `snake_case` naming conventions for the file system. The rule is enforced using ESLint. However, turn off the rule and use your preferred naming conventions. + +## Peer dependencies + +The starter kit has a peer dependency on `@adonisjs/core@6`. Since you are creating a package for AdonisJS, you must make it against a specific version of the framework core. + +If your package needs Lucid to be functional, you may install `@adonisjs/lucid` as a development dependency and add it to the list of `peerDependencies`. + +As a rule of thumb, packages installed in the user application should be part of the `peerDependencies` of your package and not the main dependency. + +For example, if you install `@adonisjs/core` as a main dependency, then essentially, you are importing a separate copy of `@adonisjs/core` and not sharing the one from the user application. Here is a great article explaining [peer dependencies](https://blog.bitsrc.io/understanding-peer-dependencies-in-javascript-dbdb4ab5a7be). + +## Published files + +Instead of publishing your repo's source code to npm, you must cherry-pick files and folders to publish only the required files. + +The cherry-picking uses the `files` property inside the `package.json` file. By default, we publish the following files and folders. + +```json +{ + "files": ["build/src", "build/providers", "build/stubs", "build/index.d.ts", "build/index.js"] +} +``` + +If you create additional folders or files, mention them inside the `files` array. + +## Exports + +[Node.js Subpath exports](https://nodejs.org/api/packages.html#subpath-exports) allows you to define the exports of your package regardless of the folder structure. This starter kit defines the following exports. + +```json +{ + "exports": { + ".": "./build/index.js", + "./types": "./build/src/types.js" + } +} +``` + +- The dot `.` export is the main export. +- The `./types` exports all the types defined inside the `./build/src/types.js` file (the compiled output). + +Feel free to change the exports as per your requirements. + +## Testing + +We configure the [Japa test runner](https://japa.dev/) with this starter kit. Japa is used in AdonisJS applications as well. Just run one of the following commands to execute tests. + +- `npm run test`: This command will first lint the code using ESlint and then run tests and report the test coverage using [c8](https://github.com/bcoe/c8). +- `npm run quick:test`: Runs only the tests without linting or coverage reporting. + +The starter kit also has a Github workflow file to run tests using Github Actions. The tests are executed against `Node.js 20.x` and `Node.js 21.x` versions on both Linux and Windows. Feel free to edit the workflow file in the `.github/workflows` directory. + +## TypeScript workflow + +- The starter kit uses [tsc](https://www.typescriptlang.org/docs/handbook/compiler-options.html) for compiling the TypeScript to JavaScript when publishing the package. +- [TS-Node](https://typestrong.org/ts-node/) and [SWC](https://swc.rs/) are used to run tests without compiling the source code. +- The `tsconfig.json` file is extended from [`@adonisjs/tsconfig`](https://github.com/adonisjs/tooling-config/tree/main/packages/typescript-config) and uses the `NodeNext` module system. Meaning the packages are written using ES modules. +- You can perform type checking without compiling the source code using the `npm run type check` script. + +Feel free to explore the `tsconfig.json` file for all the configured options. + +## ESLint and Prettier setup + +The starter kit configures ESLint and Prettier. Both configurations are stored within the `package.json` file and use our [shared config](https://github.com/adonisjs/tooling-config/tree/main/packages). Feel free to change the configuration, use custom plugins, or remove both tools altogether. + +## Using Stale bot + +The [Stale bot](https://github.com/apps/stale) is a Github application that automatically marks issues and PRs as stale and closes after a specific duration of inactivity. + +Feel free to delete the `.github/stale.yml` and `.github/lock.yml` files if you decide not to use the Stale bot. diff --git a/bin/test.ts b/bin/test.ts new file mode 100644 index 0000000..7978bf2 --- /dev/null +++ b/bin/test.ts @@ -0,0 +1,11 @@ +import { assert } from '@japa/assert' +import { configure, processCLIArgs, run } from '@japa/runner' + +processCLIArgs(process.argv.splice(2)) + +configure({ + files: ['tests/**/*.spec.ts'], + plugins: [assert()], +}) + +run() diff --git a/commands/make_dto.ts b/commands/make_dto.ts new file mode 100644 index 0000000..58d7673 --- /dev/null +++ b/commands/make_dto.ts @@ -0,0 +1,47 @@ +import { BaseCommand, args, flags } from '@adonisjs/core/ace' +import { CommandOptions } from '@adonisjs/core/types/ace' +import { stubsRoot } from '../stubs/main.js' +import DtoService from '../services/dto_service.js' +import ModelService from '../services/model_service.js' +import { ImportService } from '../services/import_service.js' + +export default class MakeDto extends BaseCommand { + static commandName = 'make:dto' + static description = "Create a new dto. If a model matches the DTO name, it'll be used by default" + static options: CommandOptions = { + strict: true, + } + + @args.string({ description: 'Name of the DTO' }) + declare name: string + + @flags.string({ + description: 'Specify a model to build the DTO from', + alias: 'm', + }) + declare model?: string + + async run() { + const modelService = new ModelService(this.app) + const dtoService = new DtoService(this.app) + + const model = await modelService.getModelInfo(this.model, this.name) + const dto = dtoService.getDtoInfo(this.name, model) + const codemods = await this.createCodemods() + + if (!model.isReadable && this.model) { + return this.logger.warning(`[WARN]: Unable to find or read desired model ${model.fileName}`) + } else if (!model.isReadable) { + return codemods.makeUsingStub(stubsRoot, 'make/dto/plain.stub', { + dto, + }) + } + + const imports = ImportService.getImportStatements(dto) + return codemods.makeUsingStub(stubsRoot, 'make/dto/main.stub', { + dto, + model, + imports, + }) + } +} diff --git a/configure.ts b/configure.ts new file mode 100644 index 0000000..201c9cd --- /dev/null +++ b/configure.ts @@ -0,0 +1,23 @@ +/* +|-------------------------------------------------------------------------- +| Configure hook +|-------------------------------------------------------------------------- +| +| The configure hook is called when someone runs "node ace configure " +| command. You are free to perform any operations inside this function to +| configure the package. +| +| To make things easier, you have access to the underlying "ConfigureCommand" +| instance and you can use codemods to modify the source files. +| +*/ + +import ConfigureCommand from '@adonisjs/core/commands/configure' + +export async function configure(command: ConfigureCommand) { + const codemods = await command.createCodemods() + + await codemods.updateRcFile((rcFile) => { + rcFile.addCommand('@adocasts.com/dto/commands') + }) +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..5843ae1 --- /dev/null +++ b/index.ts @@ -0,0 +1,10 @@ +/* +|-------------------------------------------------------------------------- +| Package entrypoint +|-------------------------------------------------------------------------- +| +| Export values from the package entrypoint as you see fit. +| +*/ + +export { configure } from './configure.js' diff --git a/package.json b/package.json new file mode 100644 index 0000000..a52586b --- /dev/null +++ b/package.json @@ -0,0 +1,87 @@ +{ + "name": "@adocasts.com/dto", + "description": "", + "version": "0.0.0", + "engines": { + "node": ">=20.6.0" + }, + "type": "module", + "files": [ + "build/commands", + "build/src", + "build/providers", + "build/stubs", + "build/index.d.ts", + "build/index.js" + ], + "exports": { + ".": "./build/index.js", + "./types": "./build/src/types.js", + "./commands": "./build/commands/main.js" + }, + "scripts": { + "clean": "del-cli build", + "copy:templates": "copyfiles \"stubs/**/*.stub\" build", + "typecheck": "tsc --noEmit", + "lint": "eslint . --ext=.ts", + "format": "prettier --write .", + "quick:test": "node --import=./tsnode.esm.js --enable-source-maps bin/test.ts", + "pretest": "npm run lint", + "test": "c8 npm run quick:test", + "prebuild": "npm run lint && npm run clean", + "build": "tsc", + "postbuild": "npm run copy:templates && npm run index:commands", + "release": "np", + "version": "npm run build", + "prepublishOnly": "npm run build", + "index:commands": "adonis-kit index build/commands" + }, + "keywords": [], + "author": "tomgobich,adocasts.com", + "license": "MIT", + "devDependencies": { + "@adonisjs/assembler": "^7.2.3", + "@adonisjs/core": "^6.3.1", + "@adonisjs/eslint-config": "^1.3.0", + "@adonisjs/prettier-config": "^1.3.0", + "@adonisjs/tsconfig": "^1.3.0", + "@japa/assert": "^2.1.0", + "@japa/runner": "^3.1.1", + "@swc/core": "^1.4.6", + "@types/node": "^20.11.25", + "c8": "^9.1.0", + "copyfiles": "^2.4.1", + "del-cli": "^5.1.0", + "eslint": "^8.57.0", + "np": "^10.0.0", + "prettier": "^3.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.4.2" + }, + "peerDependencies": { + "@adonisjs/core": "^6.2.0" + }, + "publishConfig": { + "access": "public", + "tag": "latest" + }, + "np": { + "message": "chore(release): %s", + "tag": "latest", + "branch": "main", + "anyBranch": false + }, + "c8": { + "reporter": [ + "text", + "html" + ], + "exclude": [ + "tests/**" + ] + }, + "eslintConfig": { + "extends": "@adonisjs/eslint-config/package" + }, + "prettier": "@adonisjs/prettier-config" +} diff --git a/providers/README.md b/providers/README.md new file mode 100644 index 0000000..ac2ab06 --- /dev/null +++ b/providers/README.md @@ -0,0 +1,5 @@ +# The providers directory + +The `providers` directory contains the service providers exported by your application. Make sure to register these providers within the `exports` collection (aka package entrypoints) defined within the `package.json` file. + +Learn more about [package entrypoints](https://nodejs.org/api/packages.html#package-entry-points). diff --git a/services/dto_service.ts b/services/dto_service.ts new file mode 100644 index 0000000..29f475c --- /dev/null +++ b/services/dto_service.ts @@ -0,0 +1,97 @@ +import { generators } from '@adonisjs/core/app' +import type { ModelInfo, ModelProperty, ModelPropertyType } from './model_service.js' +import string from '@adonisjs/core/helpers/string' +import { ApplicationService } from '@adonisjs/core/types' + +export type DtoInfo = { + entity: { path: string; name: string } + variable: string + className: string + fileName: string + exportPath: string + properties: DtoProperty[] +} + +export type DtoProperty = { + name: string + type: string + typeRaw: ModelPropertyType[] + valueSetter: string +} + +export default class DtoService { + constructor(protected app: ApplicationService) {} + getDtoInfo(name: string, model: ModelInfo) { + const entity = generators.createEntity(this.#getDtoName(name)) + const fileName = generators.modelFileName(entity.name).replace('_dto', '') + const data: DtoInfo = { + entity, + fileName, + className: generators.modelName(entity.name), + variable: string.camelCase(name), + exportPath: this.app.makePath('app/dtos', entity.path, fileName), + properties: [], + } + + if (!model.isReadable) return data + + data.properties = this.#getDtoProperties(model) + + return data + } + + #getDtoName(name: string) { + return name.toLowerCase().endsWith('dto') ? name : name + '_dto' + } + + #getDtoProperties(model: ModelInfo): DtoProperty[] { + return model.properties.map((property) => { + const type = this.#getDtoType(property) + return { + name: property.name, + type: type.map((item) => item.dtoType || item.type).join(' | '), + typeRaw: type, + valueSetter: this.#getValueSetter(property, model), + } + }) + } + + #getDtoType(property: ModelProperty) { + if (property.relation?.dtoType) { + return [property.relation] + } + + return property.types.map(({ ...item }) => { + if (item.type === 'DateTime') { + item.type = 'string' + } + + return item + }) + } + + #getValueSetter(property: ModelProperty, model: ModelInfo) { + const accessor = `${model.variable}.${property.name}` + + if (property.relation?.model) { + return property.relation.isPlural + ? `${property.relation.dto}.fromArray(${accessor})` + : `new ${property.relation.dto}(${accessor})` + } + + const dateTimeType = property.types.find((item) => item.type === 'DateTime') + + if (dateTimeType) { + const nullable = property.types.find((item) => item.type === 'null') + const optional = property.types.find((item) => item.type === 'optional') + + if (optional || nullable) { + return accessor + `?.toISO()${optional ? '' : '!'}` + } + + return accessor + `.toISO()!` + } + + return accessor + } +} diff --git a/services/file_service.ts b/services/file_service.ts new file mode 100644 index 0000000..89226e2 --- /dev/null +++ b/services/file_service.ts @@ -0,0 +1,19 @@ +import { access, constants, readFile } from 'node:fs/promises' + +export default class FileService { + static async canRead(filePath: string) { + try { + await access(filePath, constants.R_OK) + return true + } catch { + return false + } + } + + static async readDeclarations(filePath: string) { + const contents = await readFile(filePath, 'utf8') + return contents + .split('\n') + .filter((line) => line.includes('declare ') || line.includes('public ')) + } +} diff --git a/services/import_service.ts b/services/import_service.ts new file mode 100644 index 0000000..029e12d --- /dev/null +++ b/services/import_service.ts @@ -0,0 +1,56 @@ +import { DtoInfo } from './dto_service.js' +import string from '@adonisjs/core/helpers/string' + +export type ImportMap = { + name: string + namespace: string + isDefault: boolean +} + +export class ImportService { + static getImportStatements(dto: DtoInfo) { + const imports: ImportMap[] = [] + + for (let property of dto.properties) { + for (let item of property.typeRaw) { + if (item.isRelationship && item.dto) { + imports.push({ + name: item.dto, + namespace: '#dtos/' + string.snakeCase(item.dto).replace('_dto', ''), + isDefault: true, + }) + } + } + } + + const groups = this.#getGroupedImportNamespaces(imports) + + return Object.values(groups).map((items) => { + const defaultImport = items.find((item) => item.isDefault)?.name + const namedImports = items + .filter((item) => !item.isDefault) + .map((item) => item.name) + .join(', ') + + const names = [defaultImport, namedImports && `{ ${namedImports} }`] + .filter(Boolean) + .join(', ') + + return `import ${names} from '${items[0].namespace}'` + }) + } + + static #getGroupedImportNamespaces(imports: ImportMap[]) { + return imports.reduce>((groups, item) => { + const group = groups[item.namespace] || [] + + if (!group.some((map) => map.name === item.name)) { + group.push(item) + } + + groups[item.namespace] = group + + return groups + }, {}) + } +} diff --git a/services/model_service.ts b/services/model_service.ts new file mode 100644 index 0000000..8655f50 --- /dev/null +++ b/services/model_service.ts @@ -0,0 +1,98 @@ +import FileService from './file_service.js' +import { generators } from '@adonisjs/core/app' +import string from '@adonisjs/core/helpers/string' +import { ApplicationService } from '@adonisjs/core/types' + +export type ModelInfo = { + name: string + variable: string + fileName: string + filePath: string + isReadable: boolean + properties: ModelProperty[] +} + +export type ModelProperty = { + name: string + types: ModelPropertyType[] + relation?: ModelPropertyType +} + +export type ModelPropertyType = { + type: string + dtoType?: string + model?: string + dto?: string + isPlural?: boolean + isRelationship?: boolean + isOptionalModifier?: boolean +} + +export default class ModelService { + #relationTypes: string[] = ['BelongsTo', 'HasOne', 'HasMany', 'ManyToMany', 'HasManyThrough'] + #relationTypesPlural: string[] = ['HasMany', 'ManyToMany', 'HasManyThrough'] + + constructor(protected app: ApplicationService) {} + + async getModelInfo(modelName: string | undefined, dtoName: string) { + const name = modelName || dtoName + const fileName = generators.modelFileName(name) + const filePath = this.app.modelsPath(fileName) + const isReadable = await FileService.canRead(filePath) + const data: ModelInfo = { + name: generators.modelName(name), + variable: string.camelCase(name), + fileName, + filePath, + isReadable, + properties: [], + } + + if (!isReadable) return data + + data.properties = await this.#getModelProperties(filePath) + + return data + } + + async #getModelProperties(filePath: string): Promise { + const lines = await FileService.readDeclarations(filePath) + return lines.map((line) => { + const propertyTypeString = line.replace('declare', '').replace('public', '') + const [nameString, typeString] = propertyTypeString.split(':') + const name = nameString.replace('?', '').trim() + const typesRaw = typeString.split('|').map((type) => type.trim()) + const types = typesRaw.map((type) => this.#parseRelationType(type)) + + // when name is suffixed with optional modifier, ensure undefined is in resulting types + if (nameString.trim().endsWith('?') && !types.some((item) => item.type === 'undefined')) { + types.push({ type: 'undefined', isOptionalModifier: true }) + } + + return { + name, + types, + relation: types.find((type) => type.isRelationship), + } + }) + } + + #parseRelationType(typeRaw: string): ModelPropertyType { + if (!this.#relationTypes.some((type) => typeRaw.includes(type))) { + return { type: typeRaw } + } + + const isPlural = this.#relationTypesPlural.some((type) => typeRaw.includes(type)) + const model = typeRaw.split('typeof').at(1)?.split('>').at(0)?.trim() + const dto = model?.endsWith('Dto') ? model : model + 'Dto' + + return { + type: typeRaw, + dtoType: `${dto}${isPlural ? '[]' : ''}`, + model, + dto, + isPlural, + isRelationship: true, + } + } +} diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..2e688c7 --- /dev/null +++ b/src/README.md @@ -0,0 +1,3 @@ +# The src directory + +The `src` directory is where you organize your package code. Feel free to create additional sub-directories as you see fit. diff --git a/stubs/README.md b/stubs/README.md new file mode 100644 index 0000000..4dd2dac --- /dev/null +++ b/stubs/README.md @@ -0,0 +1,6 @@ +# The stubs directory + +The `stubs` directory stores all the stubs needed by your package. It could be config files you will publish during the initial setup or stubs you want to use within the scaffolding commands. + +- Inside the `package.json` file, we have defined a `copy:templates` script that copies the `stubs` folder to the `build` folder. +- Ensure the `build/stubs` are always published to npm via the `files` array inside the `package.json` file. diff --git a/stubs/main.ts b/stubs/main.ts new file mode 100644 index 0000000..361d6cd --- /dev/null +++ b/stubs/main.ts @@ -0,0 +1,8 @@ +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Path to the root directory where the stubs are stored. We use + * this path within commands and the configure hook + */ +export const stubsRoot = dirname(fileURLToPath(import.meta.url)) diff --git a/stubs/make/dto/main.stub b/stubs/make/dto/main.stub new file mode 100644 index 0000000..0f929e8 --- /dev/null +++ b/stubs/make/dto/main.stub @@ -0,0 +1,21 @@ +{{{ + exports({ + to: dto.exportPath + }) +}}} +import {{ model.name }} from '#models/{{ string.snakeCase(model.name) }}' +{{ #each imports as statement }} +{{{ '\n' }}}{{ statement }} +{{ /each }} + +export default class {{ dto.className }} {{{ '{' }}}{{ #each dto.properties as property }} + declare {{ property.name }}: {{{ property.type }}}{{ /each }} + + constructor({{ model.variable }}: {{ model.name }}) {{{ '{' }}}{{ #each dto.properties as property }} + this.{{ property.name }} = {{ property.valueSetter }}{{ /each }} + } + + static fromArray({{ string.plural(model.variable) }}: {{ model.name }}[]) { + return {{ string.plural(model.variable) }}.map(({{ model.variable }}) => new {{ dto.className }}({{ model.variable }})) + } +} diff --git a/stubs/make/dto/plain.stub b/stubs/make/dto/plain.stub new file mode 100644 index 0000000..b49cf38 --- /dev/null +++ b/stubs/make/dto/plain.stub @@ -0,0 +1,6 @@ +{{{ + exports({ + to: dto.exportPath + }) +}}} +export default class {{ dto.className }} {} diff --git a/test-helpers/models/user.txt b/test-helpers/models/user.txt new file mode 100644 index 0000000..d48791e --- /dev/null +++ b/test-helpers/models/user.txt @@ -0,0 +1,38 @@ +import { DateTime } from 'luxon' +import { withAuthFinder } from '@adonisjs/auth' +import hash from '@adonisjs/core/services/hash' +import { compose } from '@adonisjs/core/helpers' +import { BaseModel, column, manyToMany } from '@adonisjs/lucid/orm' +import Organization from './organization.js' +import type { ManyToMany } from '@adonisjs/lucid/types/relations' + +const AuthFinder = withAuthFinder(() => hash.use('scrypt'), { + uids: ['email'], + passwordColumnName: 'password', +}) + +export default class User extends compose(BaseModel, AuthFinder) { + @column({ isPrimary: true }) + declare id: number + + @column() + declare fullName: string | null + + @column() + declare email: string + + @column() + declare password: string + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null + + @manyToMany(() => Organization, { + pivotColumns: ['role_id'], + pivotTable: 'organization_users', + }) + declare organizations: ManyToMany +} diff --git a/tests/example.spec.ts b/tests/example.spec.ts new file mode 100644 index 0000000..893ea1a --- /dev/null +++ b/tests/example.spec.ts @@ -0,0 +1,7 @@ +import { test } from '@japa/runner' + +test.group('Example', () => { + test('add two numbers', ({ assert }) => { + assert.equal(1 + 1, 2) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0cfd318 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@adonisjs/tsconfig/tsconfig.package.json", + "compilerOptions": { + "rootDir": "./", + "outDir": "./build", + } +} diff --git a/tsnode.esm.js b/tsnode.esm.js new file mode 100644 index 0000000..0cce922 --- /dev/null +++ b/tsnode.esm.js @@ -0,0 +1,18 @@ +/* +|-------------------------------------------------------------------------- +| TS-Node ESM hook +|-------------------------------------------------------------------------- +| +| Importing this file before any other file will allow you to run TypeScript +| code directly using TS-Node + SWC. For example +| +| node --import="./tsnode.esm.js" bin/test.ts +| node --import="./tsnode.esm.js" index.ts +| +| +| Why not use "--loader=ts-node/esm"? +| Because, loaders have been deprecated. +*/ + +import { register } from 'node:module' +register('ts-node/esm', import.meta.url)