diff --git a/README.md b/README.md index 30356f7..5d5fc56 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,292 @@ -# AdonisJS package starter kit +# @adocasts.com/dto -> A boilerplate for creating AdonisJS packages +> Easily make and generate DTOs from Lucid Models -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. +Converting Lucid Models to DTO files can be a tedious task. +This package aims to make it a little less so, +by reading your model's property definitions and porting them to a DTO-safe format. +Will it be perfect? Likely not, but it should help cut back on the +repetition needed to complete the task. -## Setup +## Installation +You can easily install and configure via the Ace CLI's `add` command. +```shell +node ace add @adocasts.com/dto +``` +##### Manual Install & Configure +You can also manually install and configure if you'd prefer +```shell +npm install @adocasts.com/dto +``` +```shell +node ace configure @adocasts.com/dto +``` -- 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. +## Generate DTOs Command +Want to generate DTOs for all your models in one fell swoop? This is the command for you! +```shell +node ace generate:dtos +``` +This will read all of your model files, collecting their properties and types. +It'll then convert those property's types into serialization-safe types +and relationships into their DTO representations. -## Folder structure +``` +File Tree Class +------------------------------------------------ +└── app/ + ├── dtos/ + │ ├── account.ts AccountDto + │ ├── account_group.ts AccountGroupDto + │ ├── account_type.ts AccountTypeDto + │ ├── income.ts IncomeDto + │ ├── payee.ts PayeeDto + │ └── user.ts UserDto + └── models/ + ├── account.ts Account + ├── account_group.ts AccountGroup + ├── account_type.ts AccountType + ├── income.ts Income + ├── payee.ts Payee + └── user.ts User +``` -The starter kit mimics the folder structure of the official packages. Feel free to rename files and folders as per your requirements. +- Gets a list of your model files from the location defined within your `adonisrc.ts` file +- Reads those files as plaintext, filering down to just property definitions +- Determines the property name, it's types, whether it's a relationship, and if it's optionally modified `?` +- Converts those model types into serialized representations (currently a very loose conversion) + - Note, at present, this does not account for serialization behaviors defined on the model property (like `serializeAs`) +- Creates DTO property definitions from those conversions +- Prepares constructor value setters for each property +- Collects needed imports for relationships +- Generates the DTO file + - Note, if a file already exists at the DTOs determined location it will be skipped + +## Make DTO Command +Want to make a plain DTO file, or a single DTO from a single Model? This is the command for you! + +To make a DTO named `AccountDto` within a file located at `dto/account.ts`, we can run the following: +```shell +node ace make:dto account +``` +This will check to see if there is a model named `Account`. +If a model is found, it will use that model's property definitions to generate the `AccountDto`. +Otherwise, it'll generate just a `AccountDto` file with an empty class inside it. +``` +File Tree Class +------------------------------------------------ +└── app/ + ├── dtos/ + │ ├── account.ts AccountDto + └── models/ + ├── account.ts Account +``` +### What If There Isn't An Account Model? +As mentioned above, a plain `AccountDto` class will be generated within a new `dto/account.ts` file, which will look like the below. +```ts +export default class AccountDto {} ``` -├── providers -├── src -├── bin -├── stubs -├── configure.ts -├── index.ts -├── LICENSE.md -├── package.json -├── README.md -├── tsconfig.json -├── tsnode.esm.js + +#### Specifying A Different Model +If the DTO and Model names don't match, you can specify a specific Model to use via the `--model` flag. +```shell +node ace make:dto account --model=main_account ``` +Now instead of looking for a model named `Account` it'll instead +look for `MainAccount` and use it to create a DTO named `AccountDto`. -- 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). +## Things To Note +- At present we assume the Model's name from the file name of the model. +- There is NOT currently a setting to change the output directory of the DTOs +- Due to reflection limitations, we're reading Models as plaintext. I'm no TypeScript wiz, so if you know of a better approach, I'm all ears! + - Since we're reading as plaintext + - Currently we're omitting decorators and their options -### File system naming convention +## Example +So, we've use account as our example throughout this guide, +so let's end by taking a look at what this Account Model looks like! -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. +##### The Account Model +```ts +// app/models/account.ts -## Peer dependencies +import { DateTime } from 'luxon' +import { BaseModel, belongsTo, column, computed, hasMany, hasOne } from '@adonisjs/lucid/orm' +import User from './user.js' +import type { BelongsTo, HasMany, HasOne } from '@adonisjs/lucid/types/relations' +import AccountType from '#models/account_type' +import Payee from '#models/payee' +import Stock from '#models/stock' +import Transaction from '#models/transaction' +import AccountTypeService from '#services/account_type_service' +import { columnCurrency } from '#start/orm/column' +import type { AccountGroupConfig } from '#config/account' -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. +export default class Account extends BaseModel { + // region Columns -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`. + @column({ isPrimary: true }) + declare id: number -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. + @column() + declare userId: number -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). + @column() + declare accountTypeId: number -## Published files + @column() + declare name: string -Instead of publishing your repo's source code to npm, you must cherry-pick files and folders to publish only the required files. + @column() + declare note: string -The cherry-picking uses the `files` property inside the `package.json` file. By default, we publish the following files and folders. + @column.date() + declare dateOpened: DateTime | null -```json -{ - "files": ["build/src", "build/providers", "build/stubs", "build/index.d.ts", "build/index.js"] -} -``` + @column.date() + declare dateClosed: DateTime | null -If you create additional folders or files, mention them inside the `files` array. + @columnCurrency() + declare balance: number -## Exports + @columnCurrency() + declare startingBalance: number -[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. + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime -```json -{ - "exports": { - ".": "./build/index.js", - "./types": "./build/src/types.js" - } -} -``` + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime -- The dot `.` export is the main export. -- The `./types` exports all the types defined inside the `./build/src/types.js` file (the compiled output). + // endregion -Feel free to change the exports as per your requirements. + // region Unmapped Properties -## Testing + aggregations: Record = {} -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. + // endregion -- `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. + // region Relationships -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. + @belongsTo(() => User) + declare user: BelongsTo -## TypeScript workflow + @belongsTo(() => AccountType) + declare accountType: BelongsTo -- 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. + @hasOne(() => Payee) + declare payee: HasOne -Feel free to explore the `tsconfig.json` file for all the configured options. + @hasMany(() => Stock) + declare stocks: HasMany -## ESLint and Prettier setup + @hasMany(() => Transaction) + declare transactions: HasMany -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. + // endregion -## Using Stale bot + // region Computed Properties + + @computed() + get accountGroup(): AccountGroupConfig { + return AccountTypeService.getAccountTypeGroup(this.accountTypeId) + } -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. + @computed() + get isCreditIncrease(): boolean { + return AccountTypeService.isCreditIncreaseById(this.accountTypeId) + } + + @computed() + get isBudgetable() { + return AccountTypeService.isBudgetable(this.accountTypeId) + } + + @computed() + get balanceDisplay() { + return '$' + this.balance.toLocaleString('en-US') + } + + // endregion +} + +``` +It's got +- Column properties +- Nullable properties +- An unmapped property, which also contains a default value +- Getters +- Relationships + +Let's see what we get when we generate our DTO! +```shell +node ace make:dto account +``` + +##### The Account DTO +```ts +import Account from '#models/account' +import UserDto from '#dtos/user' +import AccountTypeDto from '#dtos/account_type' +import PayeeDto from '#dtos/payee' +import StockDto from '#dtos/stock' +import TransactionDto from '#dtos/transaction' + +export default class AccountDto { + declare id: number + declare userId: number + declare accountTypeId: number + declare name: string + declare note: string + declare dateOpened: string | null + declare dateClosed: string | null + declare balance: number + declare startingBalance: number + declare createdAt: string + declare updatedAt: string + aggregations: Record = {} + declare user: UserDto | null + declare accountType: AccountTypeDto | null + declare payee: PayeeDto | null + declare stocks: StockDto[] + declare transactions: TransactionDto[] + + constructor(account: Account) { + this.id = account.id + this.userId = account.userId + this.accountTypeId = account.accountTypeId + this.name = account.name + this.note = account.note + this.dateOpened = account.dateOpened?.toISO()! + this.dateClosed = account.dateClosed?.toISO()! + this.balance = account.balance + this.startingBalance = account.startingBalance + this.createdAt = account.createdAt.toISO()! + this.updatedAt = account.updatedAt.toISO()! + this.aggregations = account.aggregations + this.user = account.user && new UserDto(account.user) + this.accountType = account.accountType && new AccountTypeDto(account.accountType) + this.payee = account.payee && new PayeeDto(account.payee) + this.stocks = StockDto.fromArray(account.stocks) + this.transactions = TransactionDto.fromArray(account.transactions) + } + + static fromArray(accounts: Account[]) { + if (!accounts) return [] + return accounts.map((account) => new AccountDto(account)) + } +} +``` -Feel free to delete the `.github/stale.yml` and `.github/lock.yml` files if you decide not to use the Stale bot. +It's got the +- Needed imports (it'll try to get them all by also referencing the Model's imports) +- Column properties from our Model +- Nullable property's nullability +- Unmapped property from our Model, plus it's default value +- Relationships converted into DTO representations +- Constructor value setters for all of the above +- A helper method `fromArray` that'll normalize to an empty array if need be + +What it doesn't have +- The getters, we're working on it though. diff --git a/commands/generate_dtos.ts b/commands/generate_dtos.ts new file mode 100644 index 0000000..08844c9 --- /dev/null +++ b/commands/generate_dtos.ts @@ -0,0 +1,42 @@ +import { BaseCommand } from '@adonisjs/core/ace' +import { CommandOptions } from '@adonisjs/core/types/ace' +import DtoService from '../services/dto_service.js' +import ModelService from '../services/model_service.js' +import { stubsRoot } from '../stubs/main.js' +import { ImportService } from '../services/import_service.js' + +export default class GererateDtos extends BaseCommand { + static commandName = 'generate:dtos' + static description = 'Reads, converts, and generates DTOs from all Lucid Models' + static options: CommandOptions = { + strict: true, + } + + async run() { + const modelService = new ModelService(this.app) + const dtoService = new DtoService(this.app) + + const files = await modelService.getFromFiles() + const unreadable = files.filter((file) => !file.model.isReadable) + + if (unreadable.length) { + this.logger.error( + `Unable to find or read one or more models: ${unreadable.map((file) => file.model.name).join(', ')}` + ) + this.exitCode = 1 + return + } + + for (const file of files) { + const dto = dtoService.getDtoInfo(file.model.name, file.model) + const codemods = await this.createCodemods() + const imports = ImportService.getImportStatements(dto, file.modelFileLines) + + await codemods.makeUsingStub(stubsRoot, 'make/dto/main.stub', { + model: file.model, + dto, + imports, + }) + } + } +} diff --git a/commands/make_dto.ts b/commands/make_dto.ts index 58d7673..1c428b0 100644 --- a/commands/make_dto.ts +++ b/commands/make_dto.ts @@ -7,12 +7,15 @@ 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 description = 'Create a new dto' static options: CommandOptions = { strict: true, } - @args.string({ description: 'Name of the DTO' }) + @args.string({ + description: + "Name of the DTO. If a model matches the provided name, it'll be used to generate the DTO", + }) declare name: string @flags.string({ @@ -25,19 +28,24 @@ export default class MakeDto extends BaseCommand { const modelService = new ModelService(this.app) const dtoService = new DtoService(this.app) - const model = await modelService.getModelInfo(this.model, this.name) + const { model, modelFileLines } = 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}`) + // wanted to generate from model, but model couldn't be found or read? cancel with error + this.logger.error(`Unable to find or read desired model ${model.fileName}`) + this.exitCode = 1 + return } else if (!model.isReadable) { + // model not specifically wanted and couldn't be found or read? create plain DTO return codemods.makeUsingStub(stubsRoot, 'make/dto/plain.stub', { dto, }) } - const imports = ImportService.getImportStatements(dto) + const imports = ImportService.getImportStatements(dto, modelFileLines) + return codemods.makeUsingStub(stubsRoot, 'make/dto/main.stub', { dto, model, diff --git a/services/dto_service.ts b/services/dto_service.ts index 29f475c..63b1856 100644 --- a/services/dto_service.ts +++ b/services/dto_service.ts @@ -16,11 +16,18 @@ export type DtoProperty = { name: string type: string typeRaw: ModelPropertyType[] + declaration: string valueSetter: string } export default class DtoService { constructor(protected app: ApplicationService) {} + + /** + * Get DTO file, class, and property info + * @param name + * @param model + */ getDtoInfo(name: string, model: ModelInfo) { const entity = generators.createEntity(this.#getDtoName(name)) const fileName = generators.modelFileName(entity.name).replace('_dto', '') @@ -40,25 +47,50 @@ export default class DtoService { return data } + /** + * Normalize name of the DTO + * @param name + * @private + */ #getDtoName(name: string) { return name.toLowerCase().endsWith('dto') ? name : name + '_dto' } + /** + * Get DTO's property, type, and constructor value setting info + * @param model + * @private + */ #getDtoProperties(model: ModelInfo): DtoProperty[] { return model.properties.map((property) => { - const type = this.#getDtoType(property) + const typeRaw = this.#getDtoType(property) + const type = typeRaw.map((item) => item.dtoType || item.type).join(' | ') return { name: property.name, - type: type.map((item) => item.dtoType || item.type).join(' | '), - typeRaw: type, + type, + typeRaw: typeRaw, + declaration: this.#getPropertyDeclaration(property, type), valueSetter: this.#getValueSetter(property, model), } }) } + /** + * Get normalized DTO types + * @param property + * @private + */ #getDtoType(property: ModelProperty) { if (property.relation?.dtoType) { - return [property.relation] + const types = [property.relation] + + // plural relationships will be normalized to empty array + // however, singular relationships may be null if not loaded/preloaded regardless of type + if (!property.relation.isPlural) { + types.push({ type: 'null' }) + } + + return types } return property.types.map(({ ...item }) => { @@ -70,26 +102,43 @@ export default class DtoService { }) } + /** + * get class declaration string for property + * @param property + * @param type + * @private + */ + #getPropertyDeclaration(property: ModelProperty, type: string) { + if (!property.defaultValue) { + return `declare ${property.name}${property.isOptionallyModified ? '?' : ''}: ${type}` + } + + return `${property.name}: ${type} = ${property.defaultValue}` + } + + /** + * Get value setter for use in the constructor for the property + * @param property + * @param model + * @private + */ #getValueSetter(property: ModelProperty, model: ModelInfo) { + const nullable = property.types.find((item) => item.type === 'null') + const optional = property.types.find((item) => item.type === 'optional') const accessor = `${model.variable}.${property.name}` if (property.relation?.model) { return property.relation.isPlural ? `${property.relation.dto}.fromArray(${accessor})` - : `new ${property.relation.dto}(${accessor})` + : `${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 optional || nullable + ? accessor + `?.toISO()${optional ? '' : '!'}` + : accessor + `.toISO()!` } return accessor diff --git a/services/file_service.ts b/services/file_service.ts index 89226e2..a51ee7e 100644 --- a/services/file_service.ts +++ b/services/file_service.ts @@ -1,6 +1,11 @@ import { access, constants, readFile } from 'node:fs/promises' +import string from '@adonisjs/core/helpers/string' export default class FileService { + /** + * Determines whether the provided file path exists and has allow-read permissions + * @param filePath + */ static async canRead(filePath: string) { try { await access(filePath, constants.R_OK) @@ -10,10 +15,35 @@ export default class FileService { } } + /** + * Get's a files property declarations lines (declare | public) + * @param filePath + */ static async readDeclarations(filePath: string) { const contents = await readFile(filePath, 'utf8') - return contents - .split('\n') - .filter((line) => line.includes('declare ') || line.includes('public ')) + const fileLines = contents.split('\n') + // const definitions = fileLines.filter( + // (line) => line.includes('declare ') || line.includes('public ') || line.includes('get ') || + // ) + const classStartIndex = fileLines.findIndex((line) => line.includes(' extends BaseModel ')) + const classEndIndex = fileLines.findLastIndex((line) => string.condenseWhitespace(line) === '}') + + const classLines = fileLines + .slice(classStartIndex + 1, classEndIndex - 1) + .map((line) => string.condenseWhitespace(line)) + + let isInBlock: boolean = false + const definitions = classLines.filter((line) => { + const propertyMatch = line.match(/^(declare |public |get |[0-9A-z])+/) + + if (line.endsWith('{')) isInBlock = true + if (line.startsWith('}') && isInBlock) isInBlock = false + + return propertyMatch && !isInBlock + }) + + console.log({ classLines, definitions, classStartIndex, classEndIndex, fileLines }) + + return { definitions, fileLines } } } diff --git a/services/import_service.ts b/services/import_service.ts index 029e12d..bab1f35 100644 --- a/services/import_service.ts +++ b/services/import_service.ts @@ -1,5 +1,6 @@ -import { DtoInfo } from './dto_service.js' +import { DtoInfo, DtoProperty } from './dto_service.js' import string from '@adonisjs/core/helpers/string' +import UtilService from './util_service.js' export type ImportMap = { name: string @@ -7,11 +8,24 @@ export type ImportMap = { isDefault: boolean } +export type ImportLine = { + name: string + names: string[] + namespace: string + line: string +} + export class ImportService { - static getImportStatements(dto: DtoInfo) { + /** + * Get grouped import statements from generated DTO type information + * @param dto + */ + static getImportStatements(dto: DtoInfo, modelFileLines: string[]) { const imports: ImportMap[] = [] + const importLines = this.#getImportFileLines(modelFileLines) for (let property of dto.properties) { + // get imports for relationship DTOs for (let item of property.typeRaw) { if (item.isRelationship && item.dto) { imports.push({ @@ -21,8 +35,22 @@ export class ImportService { }) } } + + const importMatch = this.#findImportLineMatch(property, importLines) + + if (importMatch) { + imports.push(importMatch) + } } + // don't try to import the DTO we're generating + const nonSelfReferencingImports = imports.filter((imp) => imp.name !== dto.className) + + // join default and named imports into a single import statement for the namespace + return this.#buildImportStatements(nonSelfReferencingImports) + } + + static #buildImportStatements(imports: ImportMap[]) { const groups = this.#getGroupedImportNamespaces(imports) return Object.values(groups).map((items) => { @@ -40,6 +68,11 @@ export class ImportService { }) } + /** + * groups imports by namespace + * @param imports + * @private + */ static #getGroupedImportNamespaces(imports: ImportMap[]) { return imports.reduce>((groups, item) => { const group = groups[item.namespace] || [] @@ -53,4 +86,56 @@ export class ImportService { return groups }, {}) } + + static #getImportFileLines(fileLines: string[]): ImportLine[] { + const lines = fileLines.filter((line) => line.startsWith('import ')) + return lines.map((line) => { + const part = line.replace('import ', '').replace('type ', '').split('from ').at(0) + const namespace = line.split('from ').at(1) || '' + const [defaultPart = '', namedPart = ''] = part?.split('{') || [] + const names = + namedPart + .split('}') + .at(0) + ?.split(',') + .map((name) => name.trim()) || [] + + return { + name: UtilService.cleanDefinition(defaultPart), + names: names.filter((name) => name !== ''), + namespace, + line, + } + }) + } + + static #findImportLineMatch(property: DtoProperty, lines: ImportLine[]) { + const types = property.type.split('|').map((type) => type.trim()) + const defaultMatch = lines.find((line) => types.includes(line.name)) + + if (defaultMatch) { + return { + name: defaultMatch.name, + namespace: UtilService.cleanDefinition(defaultMatch.namespace), + isDefault: true, + } + } + + let namedMatch: { name: string; namespace: string } | undefined + + for (const line of lines) { + const name = line.names.find((item) => types.includes(item)) + if (!name) continue + namedMatch = { name, namespace: line.namespace } + break + } + + if (!namedMatch) return + + return { + name: namedMatch.name, + namespace: UtilService.cleanDefinition(namedMatch.namespace), + isDefault: false, + } + } } diff --git a/services/model_service.ts b/services/model_service.ts index 8655f50..a7f1192 100644 --- a/services/model_service.ts +++ b/services/model_service.ts @@ -2,6 +2,13 @@ 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' +import { fsReadAll } from '@adonisjs/core/helpers' +import UtilService from './util_service.js' + +export type ModelMap = { + name: string + filePath: string +} export type ModelInfo = { name: string @@ -16,6 +23,8 @@ export type ModelProperty = { name: string types: ModelPropertyType[] relation?: ModelPropertyType + defaultValue?: string + isOptionallyModified: boolean } export type ModelPropertyType = { @@ -25,7 +34,6 @@ export type ModelPropertyType = { dto?: string isPlural?: boolean isRelationship?: boolean - isOptionalModifier?: boolean } export default class ModelService { @@ -34,10 +42,28 @@ export default class ModelService { constructor(protected app: ApplicationService) {} - async getModelInfo(modelName: string | undefined, dtoName: string) { - const name = modelName || dtoName + async getFromFiles() { + const modelsPath = this.app.modelsPath() + const modelFilePaths = await fsReadAll(modelsPath, { pathType: 'absolute' }) + const promises = modelFilePaths.map(async (filePath) => { + const name = generators.modelName(filePath.split('/').pop()!) + return this.getModelInfo({ name, filePath }, name) + }) + + return await Promise.all(promises) + } + + /** + * Get model's validity, file, class, and property information + * @param modelName + * @param dtoName + */ + async getModelInfo(modelName: ModelMap | string | undefined, dtoName: string) { + const name = (typeof modelName === 'object' ? modelName.name : modelName) || dtoName const fileName = generators.modelFileName(name) - const filePath = this.app.modelsPath(fileName) + const filePath = + typeof modelName === 'object' ? modelName.filePath : this.app.modelsPath(fileName) + const isReadable = await FileService.canRead(filePath) const data: ModelInfo = { name: generators.modelName(name), @@ -48,35 +74,53 @@ export default class ModelService { properties: [], } - if (!isReadable) return data + if (!isReadable) return { model: data, modelFileLines: [] } + + const { definitions, fileLines } = await FileService.readDeclarations(filePath) - data.properties = await this.#getModelProperties(filePath) + data.properties = await this.#getModelProperties(definitions) - return data + return { model: data, modelFileLines: fileLines } } - 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)) + /** + * Get model property, relationship, and type info + * @param definitions + * @private + */ + async #getModelProperties(definitions: string[]): Promise { + return definitions.map((definition) => { + const propertyTypeString = definition.replace('declare', '').replace('public', '') + const [propertyLeft, propertyRight = ''] = propertyTypeString.split(':') + const name = UtilService.cleanDefinition(propertyLeft) + let { typeString, valueString } = UtilService.getTypeAndValue(propertyRight) - // 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 }) + if (!typeString) { + typeString = UtilService.getDefaultType(name) } + const typesRaw = typeString.split('|').map((type) => type.trim()) + const types = typesRaw.map((type) => this.#parseRelationType(type)) + const defaultValue = valueString?.trim() + const relation = types.find((type) => type.isRelationship) + const isOptionallyModified = + propertyLeft.trim().endsWith('?') && !types.some((item) => item.type === 'undefined') + return { name, types, - relation: types.find((type) => type.isRelationship), + defaultValue, + relation, + isOptionallyModified, } }) } + /** + * Gets relationship type information from raw type string + * @param typeRaw + * @private + */ #parseRelationType(typeRaw: string): ModelPropertyType { if (!this.#relationTypes.some((type) => typeRaw.includes(type))) { return { type: typeRaw } diff --git a/services/util_service.ts b/services/util_service.ts new file mode 100644 index 0000000..70f2649 --- /dev/null +++ b/services/util_service.ts @@ -0,0 +1,36 @@ +export default class UtilService { + static cleanDefinition(part: string) { + return part + .replace('get ', '') + .replace('(', '') + .replace(')', '') + .replace('?', '') + .replace('{', '') + .replace('}', '') + .replaceAll("'", '') + .replaceAll('"', '') + .trim() + } + + static getTypeAndValue(part: string) { + let [typeString = '', valueString = ''] = part + .split('=') + .map((t) => (t.trim() === '' ? undefined : t)) + .filter(Boolean) + + typeString = this.cleanDefinition(typeString) + + return { + typeString, + valueString: valueString.trim(), + } + } + + static getDefaultType(name: string) { + if (name.startsWith('is')) { + return 'boolean' + } + + return 'string' + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e69de29 diff --git a/stubs/make/dto/main.stub b/stubs/make/dto/main.stub index 0f929e8..1617a4b 100644 --- a/stubs/make/dto/main.stub +++ b/stubs/make/dto/main.stub @@ -9,13 +9,14 @@ import {{ model.name }} from '#models/{{ string.snakeCase(model.name) }}' {{ /each }} export default class {{ dto.className }} {{{ '{' }}}{{ #each dto.properties as property }} - declare {{ property.name }}: {{{ property.type }}}{{ /each }} + {{{ property.declaration }}}{{ /each }} constructor({{ model.variable }}: {{ model.name }}) {{{ '{' }}}{{ #each dto.properties as property }} - this.{{ property.name }} = {{ property.valueSetter }}{{ /each }} + this.{{ property.name }} = {{{ property.valueSetter }}}{{ /each }} } static fromArray({{ string.plural(model.variable) }}: {{ model.name }}[]) { + if (!{{ string.plural(model.variable) }}) return [] return {{ string.plural(model.variable) }}.map(({{ model.variable }}) => new {{ dto.className }}({{ model.variable }})) } }