├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── ci.yml ├── pnpm-workspace.yaml ├── packages ├── core │ ├── README.md │ ├── src │ │ ├── utils.ts │ │ ├── index.ts │ │ ├── config.ts │ │ ├── builder │ │ │ ├── states_manager.ts │ │ │ ├── relationship_builder.ts │ │ │ └── builder.ts │ │ ├── model.ts │ │ └── contracts.ts │ └── package.json └── japa-plugin │ ├── build.config.ts │ ├── src │ └── index.ts │ ├── README.md │ └── package.json ├── docs ├── assets │ └── intro.png ├── public │ ├── logo.png │ └── banner.png ├── .vitepress │ ├── theme │ │ ├── index.ts │ │ └── custom.css │ └── config.ts ├── package.json ├── index.md ├── integrations │ └── japa.md ├── getting-started │ └── installation.md └── guide │ ├── using-factories.md │ └── defining-factories.md ├── .prettierignore ├── .gitignore ├── .editorconfig ├── compose.yml ├── tsconfig.json ├── README.md ├── LICENSE.md ├── tests-helpers ├── setup.ts └── db.ts ├── bin └── test.ts ├── tests ├── has_one.spec.ts ├── belongs_to.spec.ts ├── has_many.spec.ts └── core.spec.ts └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Julien-R44] 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - docs 4 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @julr/factorify 2 | 3 | The Factorify core package. 4 | -------------------------------------------------------------------------------- /docs/assets/intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Julien-R44/factorify/HEAD/docs/assets/intro.png -------------------------------------------------------------------------------- /docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Julien-R44/factorify/HEAD/docs/public/logo.png -------------------------------------------------------------------------------- /docs/public/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Julien-R44/factorify/HEAD/docs/public/banner.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | *.md 4 | config.json 5 | .eslintrc.json 6 | package.json 7 | *.html 8 | *.txt 9 | dist 10 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme' 2 | import './custom.css' 3 | 4 | export default DefaultTheme 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | .idea 4 | *.log 5 | *.tgz 6 | coverage 7 | dist 8 | lib-cov 9 | logs 10 | node_modules 11 | temp 12 | ./test 13 | *.sqlite 14 | -------------------------------------------------------------------------------- /packages/japa-plugin/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | 3 | export default defineBuildConfig({ 4 | entries: ['src/index'], 5 | declaration: true, 6 | clean: true, 7 | rollup: { 8 | emitCJS: true, 9 | }, 10 | externals: ['knex'], 11 | }) 12 | -------------------------------------------------------------------------------- /packages/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | import humps from 'humps' 2 | import type { CasingStrategy } from './contracts.js' 3 | 4 | export function convertCase(obj: Record, casing: CasingStrategy) { 5 | if (casing === 'camel') { 6 | return humps.camelizeKeys(obj) 7 | } 8 | 9 | if (casing === 'snake') { 10 | return humps.decamelizeKeys(obj) 11 | } 12 | 13 | return obj 14 | } 15 | -------------------------------------------------------------------------------- /packages/japa-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import { defineFactorifyConfig } from '@julr/factorify' 2 | import type { PluginFn } from '@japa/runner' 3 | 4 | export function factorify(options: Parameters[0]): PluginFn { 5 | const disconnect = defineFactorifyConfig(options) 6 | 7 | return async function (config) { 8 | config.teardown.push(disconnect) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [*.json] 10 | insert_final_newline = ignore 11 | 12 | [**.min.js] 13 | indent_style = ignore 14 | insert_final_newline = ignore 15 | 16 | [MakeFile] 17 | indent_style = space 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@julr/factorify-docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "", 6 | "author": "Julien Ripouteau ", 7 | "license": "ISC", 8 | "keywords": [], 9 | "main": "index.js", 10 | "scripts": { 11 | "dev": "vitepress dev", 12 | "build": "vitepress build", 13 | "serve": "vitepress serve" 14 | }, 15 | "devDependencies": { 16 | "vitepress": "1.0.0-alpha.16", 17 | "vue": "^3.2.39" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | import { defineFactorifyConfig } from './config' 2 | import { FactoryModel } from './model' 3 | import type { Builder } from './builder/builder' 4 | import type { DefineFactoryCallback } from './contracts' 5 | 6 | export { defineFactorifyConfig, FactoryModel, Builder } 7 | 8 | /** 9 | * Define a new factory. 10 | */ 11 | export function defineFactory>( 12 | table: string, 13 | cb: DefineFactoryCallback 14 | ) { 15 | return new FactoryModel(table, cb) 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v2 18 | 19 | - name: Set node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 16.x 23 | cache: pnpm 24 | 25 | - run: npx changelogithub 26 | env: 27 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 28 | -------------------------------------------------------------------------------- /packages/japa-plugin/README.md: -------------------------------------------------------------------------------- 1 | # @julr/japa-factorify-plugin 2 | 3 | The Japa plugin for Factorify. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | pnpm add @julr/japa-factorify-plugin 9 | ``` 10 | 11 | ```ts 12 | // bin/test.ts 13 | 14 | configure({ 15 | ...processCliArgs(process.argv.slice(2)), 16 | ...{ 17 | plugins: [ 18 | factorify({ 19 | database: { 20 | // See https://knexjs.org/guide/#configuration-options 21 | // for more information 22 | connection: { 23 | host: 'localhost', 24 | user: 'root', 25 | password: 'password', 26 | database: 'factorify', 27 | } 28 | }, 29 | }), 30 | ], 31 | }, 32 | // ... 33 | }) 34 | 35 | // ... 36 | ``` 37 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mysql: 3 | platform: linux/x86_64 4 | image: mysql:8.0 5 | environment: 6 | MYSQL_DATABASE: japa 7 | MYSQL_USER: japa 8 | MYSQL_PASSWORD: password 9 | MYSQL_ROOT_PASSWORD: password 10 | ports: 11 | - '3306:3306' 12 | expose: 13 | - '3306' 14 | 15 | pg: 16 | image: postgres:11 17 | environment: 18 | POSTGRES_DB: japa 19 | POSTGRES_USER: japa 20 | POSTGRES_PASSWORD: password 21 | ports: 22 | - 5432:5432 23 | expose: 24 | - '5432' 25 | 26 | mssql: 27 | image: mcr.microsoft.com/mssql/server:2019-latest 28 | ports: 29 | - 1433:1433 30 | expose: 31 | - '1433' 32 | environment: 33 | SA_PASSWORD: 'arandom&233password' 34 | ACCEPT_EULA: 'Y' 35 | -------------------------------------------------------------------------------- /packages/core/src/config.ts: -------------------------------------------------------------------------------- 1 | import knex from 'knex' 2 | import { faker } from '@faker-js/faker' 3 | import type { UsableLocale } from '@faker-js/faker' 4 | import type { Knex } from 'knex' 5 | import type { FactorifyConfig } from './contracts' 6 | 7 | /** 8 | * Global configuration 9 | */ 10 | export const factorifyConfig = { 11 | knex: null as Knex | null, 12 | casing: { 13 | insert: 'snake', 14 | return: 'camel', 15 | } as NonNullable, 16 | } 17 | 18 | /** 19 | * Define the Factorify configuration. 20 | * 21 | * Returns a function that can be used to clean up the connection. 22 | */ 23 | export const defineFactorifyConfig = (options: FactorifyConfig & { locale?: UsableLocale }) => { 24 | faker.locale = options.locale || faker.locale 25 | 26 | factorifyConfig.knex = knex(options.database) 27 | factorifyConfig.casing = options.casing || factorifyConfig.casing 28 | 29 | return () => { 30 | if (factorifyConfig.knex) { 31 | factorifyConfig.knex.destroy() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # What is Factorify ? 2 | 3 | ![Factorify](./assets/intro.png) 4 | 5 | Have you ever written tests, in which the first 15-20 lines of each test are dedicated to just setting up the database state by using multiple models? With Factorify, you can extract all this set up to a dedicated file and then write the bare minimum code to set up the database state. 6 | 7 | Factorify is framework-agnostic, that means you can use it with any test runner or framework. It also support multiple databases ( SQLite, Postgres, MySQL, MSSQL ... ) 8 | 9 | Built-on top of [Knex](https://knexjs.org) + [Faker](https://fakerjs.dev/), and **heavily** inspired by [Adonis.js](https://adonisjs.com/) and [Laravel](https://laravel.com/). 10 | 11 | ## Features 12 | 13 | - Support for multiple databases ( SQLite, Postgres, MySQL, MSSQL ... ) 14 | - Integrations with [test runners](./integrations/japa.md) 15 | - Define variations of your model using [states](./guide/defining-factories.md#states) 16 | - Define [relations](./guide//defining-factories.md#relationships) 17 | - Generate in-memory instances 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "transpileOnly": true 4 | }, 5 | 6 | "compilerOptions": { 7 | "target": "ES2017", 8 | "module": "esnext", 9 | "lib": ["esnext"], 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "allowUnreachableCode": false, 13 | "allowUnusedLabels": false, 14 | "noImplicitAny": true, 15 | "noImplicitOverride": true, 16 | "noImplicitThis": true, 17 | "noUncheckedIndexedAccess": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "strict": true, 21 | "useUnknownInCatchVariables": true, 22 | "baseUrl": "./", 23 | "resolveJsonModule": true, 24 | "removeComments": true, 25 | "skipLibCheck": true, 26 | "allowSyntheticDefaultImports": true, 27 | "esModuleInterop": true, 28 | "forceConsistentCasingInFileNames": true, 29 | "experimentalDecorators": true, 30 | "isolatedModules": true, 31 | 32 | "paths": { 33 | "@julr/factorify": ["./packages/core/src/index.ts"], 34 | "@julr/japa-factorify-plugin": ["./packages/japa-plugin/src/index.ts"] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | 6 | # @julr/factorify 7 | 8 | Framework-agnostic model factory system for clean testing. 9 | 10 | Built-on top of [Knex](https://knexjs.org) + [Faker](https://fakerjs.dev/), and **heavily** inspired by [Adonis.js](https://adonisjs.com/) and [Laravel](https://laravel.com/). 11 | 12 | > Have you ever written tests, in which the first 15-20 lines of each test are dedicated to just setting up the database state by using multiple models? With Factorify, you can extract all this set up to a dedicated file and then write the bare minimum code to set up the database state. 13 | 14 | ## Features 15 | - Support for multiple databases ( SQLite, Postgres, MySQL, MSSQL ... ) 16 | - Integrations with [test runners](#integrations) 17 | - Define variations of your model using [states](#factory-states) 18 | - Define [relations](#relationships) 19 | - Generate in-memory instances 20 | 21 | ## Getting Started 22 | 23 | Please follow the documentation at [factorify.julr.dev](https://factorify.julr.dev/) ! 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Julien Ripouteau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/japa-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@julr/japa-factorify-plugin", 3 | "version": "1.0.1", 4 | "description": "The Japa plugin for Factorify", 5 | "author": "Julien Ripouteau ", 6 | "license": "ISC", 7 | "keywords": [ 8 | "japa", 9 | "japa plugin", 10 | "knex", 11 | "factorify", 12 | "model factory" 13 | ], 14 | "sideEffects": false, 15 | "exports": { 16 | ".": { 17 | "types": "./dist/index.d.ts", 18 | "require": "./dist/index.cjs", 19 | "import": "./dist/index.mjs" 20 | } 21 | }, 22 | "main": "dist/index.cjs", 23 | "module": "dist/index.mjs", 24 | "types": "dist/index.d.ts", 25 | "files": [ 26 | "dist" 27 | ], 28 | "publishConfig": { 29 | "access": "public" 30 | }, 31 | "scripts": { 32 | "build": "unbuild", 33 | "stub": "unbuild --stub" 34 | }, 35 | "peerDependencies": { 36 | "@japa/runner": "^2.0.0", 37 | "@julr/factorify": "^1.0.0-beta.0" 38 | }, 39 | "devDependencies": { 40 | "@japa/runner": "^2.2.1", 41 | "@julr/factorify": "link:../core", 42 | "knex": "^2.3.0", 43 | "typescript": "^4.8.3", 44 | "unbuild": "^0.8.11" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@julr/factorify", 3 | "version": "1.0.1", 4 | "description": "", 5 | "author": "Julien Ripouteau ", 6 | "license": "MIT", 7 | "funding": "https://github.com/sponsors/Julien-R44", 8 | "homepage": "https://github.com/Julien-R44/factorify#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/Julien-R44/factorify.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/Julien-R44/factorify/issues" 15 | }, 16 | "keywords": [], 17 | "sideEffects": false, 18 | "exports": { 19 | ".": { 20 | "types": "./dist/index.d.ts", 21 | "require": "./dist/index.cjs", 22 | "import": "./dist/index.mjs" 23 | } 24 | }, 25 | "main": "./dist/index.cjs", 26 | "module": "./dist/index.mjs", 27 | "types": "./dist/index.d.ts", 28 | "files": [ 29 | "dist" 30 | ], 31 | "publishConfig": { 32 | "access": "public" 33 | }, 34 | "scripts": { 35 | "build": "unbuild", 36 | "stub": "unbuild --stub" 37 | }, 38 | "dependencies": { 39 | "@faker-js/faker": "^7.5.0", 40 | "@types/humps": "^2.0.2", 41 | "defu": "^6.1.0", 42 | "humps": "^2.0.1", 43 | "knex": "^2.3.0" 44 | }, 45 | "devDependencies": { 46 | "typescript": "^4.8.3", 47 | "unbuild": "^0.8.11" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/core/src/builder/states_manager.ts: -------------------------------------------------------------------------------- 1 | import defu from 'defu' 2 | import type { FactoryModel } from '../model' 3 | 4 | export class StatesManager { 5 | constructor(private factory: FactoryModel) {} 6 | 7 | /** 8 | * Registered states that need to be applied on the 9 | * next created models. 10 | */ 11 | private registeredStates: Set = new Set() 12 | 13 | /** 14 | * Get the callback for the given state. 15 | */ 16 | getStateCallback(state: States) { 17 | if (typeof state !== 'string') { 18 | throw new TypeError('You must provide a state name to apply') 19 | } 20 | 21 | const stateCallback = this.factory.states[state] 22 | if (!stateCallback) { 23 | throw new Error(`The state "${state}" does not exist on the factory`) 24 | } 25 | 26 | return stateCallback 27 | } 28 | 29 | /** 30 | * Apply the registered states on the given rows. 31 | */ 32 | public applyStates(rows: Record[]) { 33 | const states = Array.from(this.registeredStates) 34 | 35 | for (const state of states) { 36 | const stateCallback = this.getStateCallback(state) 37 | rows = rows.map((row) => defu(stateCallback(row), row)) 38 | } 39 | 40 | return rows 41 | } 42 | 43 | /** 44 | * Register a state to be applied on the next created models. 45 | */ 46 | public register(state: States) { 47 | this.registeredStates.add(state) 48 | } 49 | 50 | /** 51 | * Unregister all states. 52 | */ 53 | public reset() { 54 | this.registeredStates = new Set() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests-helpers/setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-use-before-define */ 2 | import { defineFactory } from '@julr/factorify' 3 | import type { Builder } from '@julr/factorify' 4 | 5 | export const ProfileFactory = defineFactory('profile', ({ faker }) => ({ 6 | age: faker.datatype.number(), 7 | email: faker.internet.email(), 8 | })) 9 | .state('old', () => ({ age: '150' })) 10 | .state('admin', () => ({ email: 'admin@admin.com' })) 11 | .build() 12 | 13 | export const PostFactory = defineFactory('post', ({ faker }) => ({ 14 | title: faker.lorem.sentence(), 15 | })) 16 | .state('nodeArticle', () => ({ title: 'NodeJS' })) 17 | .build() 18 | 19 | export const AdminFactory = defineFactory('admin', ({ faker }) => ({ 20 | id: faker.datatype.number(), 21 | })).build() 22 | 23 | export const UserFactory = defineFactory('user', ({ faker }) => ({ 24 | id: faker.datatype.number(), 25 | })) 26 | .state('easyPassword', () => ({ password: 'easy' })) 27 | .state('easyEmail', () => ({ email: 'easy@easy.com' })) 28 | .hasOne('profile', () => ProfileFactory) 29 | .hasMany('posts', () => PostFactory) 30 | .hasOne('account', () => AccountFactory) 31 | .build() as Builder 32 | 33 | export const AccountFactory = defineFactory('account', ({ faker }) => ({ 34 | name: faker.commerce.productName(), 35 | })) 36 | .belongsTo('user', () => UserFactory) 37 | .belongsTo('admin', () => AdminFactory) 38 | .build() 39 | 40 | export const TestFactory = defineFactory('account', ({ faker }) => ({ 41 | name: faker.commerce.productName(), 42 | })) 43 | .hasOne('admin', () => AdminFactory) 44 | .build() 45 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import { pathToFileURL } from 'node:url' 2 | import { expect } from '@japa/expect' 3 | import { specReporter } from '@japa/spec-reporter' 4 | import { configure, processCliArgs, run } from '@japa/runner' 5 | import { database } from '@julr/japa-database-plugin' 6 | import { factorify } from '@julr/japa-factorify-plugin' 7 | import { connection, connectionConfig } from '../tests-helpers/db.js' 8 | 9 | /* 10 | |-------------------------------------------------------------------------- 11 | | Configure tests 12 | |-------------------------------------------------------------------------- 13 | | 14 | | The configure method accepts the configuration to configure the Japa 15 | | tests runner. 16 | | 17 | | The first method call "processCliArgs" process the command line arguments 18 | | and turns them into a config object. Using this method is not mandatory. 19 | | 20 | | Please consult japa.dev/runner-config for the config docs. 21 | */ 22 | 23 | configure({ 24 | ...processCliArgs(process.argv.slice(2)), 25 | ...{ 26 | files: ['tests/**/*.spec.ts'], 27 | plugins: [ 28 | database({ database: connectionConfig }), 29 | factorify({ database: connectionConfig, locale: 'fr' }), 30 | expect(), 31 | ], 32 | reporters: [specReporter({ stackLinesCount: 2 })], 33 | teardown: [ 34 | async () => { 35 | await connection.destroy() 36 | }, 37 | ], 38 | importer: (filePath) => import(pathToFileURL(filePath).href), 39 | }, 40 | }) 41 | 42 | /* 43 | |-------------------------------------------------------------------------- 44 | | Run tests 45 | |-------------------------------------------------------------------------- 46 | | 47 | | The following "run" method is required to execute all the tests. 48 | | 49 | */ 50 | run() 51 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | pull_request: 10 | branches: 11 | - main 12 | - master 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Install pnpm 21 | uses: pnpm/action-setup@v2.2.1 22 | 23 | - name: Set node 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: 16.x 27 | cache: pnpm 28 | 29 | - name: Install 30 | run: pnpm install --frozen-lockfile -r 31 | 32 | - name: Lint 33 | run: pnpm lint 34 | 35 | typecheck: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v2 39 | 40 | - name: Install pnpm 41 | uses: pnpm/action-setup@v2.2.1 42 | 43 | - name: Set node 44 | uses: actions/setup-node@v2 45 | with: 46 | node-version: 16.x 47 | cache: pnpm 48 | 49 | - name: Install 50 | run: pnpm install --frozen-lockfile -r 51 | 52 | - name: Typecheck 53 | run: pnpm typecheck 54 | 55 | test: 56 | runs-on: ubuntu-latest 57 | 58 | steps: 59 | - uses: actions/checkout@v2 60 | 61 | - name: Install pnpm 62 | uses: pnpm/action-setup@v2.2.1 63 | - name: Set node version to ${{ matrix.node }} 64 | uses: actions/setup-node@v2 65 | with: 66 | node-version: ${{ matrix.node }} 67 | cache: pnpm 68 | - name: Install 69 | run: pnpm install --frozen-lockfile -r 70 | 71 | - name: Build 72 | run: pnpm build 73 | 74 | - name: Launch containers 75 | run: docker-compose up -d 76 | 77 | - name: Test 78 | run: pnpm test:postgres && pnpm test:sqlite && pnpm test:better_sqlite 79 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | 4 | export default defineConfig({ 5 | lang: 'en-US', 6 | title: 'Factorify', 7 | description: 'Node.js framework-agnostic model factories for clean testing', 8 | 9 | head: [ 10 | ['link', { rel: 'icon', href: '/logo.png' }], 11 | [ 12 | 'meta', 13 | { name: 'twitter:image', content: 'https://factorify.julr.dev/banner.png', } 14 | ], 15 | ['meta', { name: 'twitter:site', content: '@julien_rpt' }], 16 | ['meta', { name: 'twitter:card', content: 'summary' }], 17 | ], 18 | 19 | themeConfig: { 20 | siteTitle: 'Factorify', 21 | logo: '/logo.png', 22 | 23 | 24 | nav: [ 25 | { text: 'Release Notes', link: 'https://github.com/Julien-R44/factorify/releases' }, 26 | { text: 'Sponsoring', link: 'https://github.com/sponsors/Julien-R44' }, 27 | ], 28 | 29 | sidebar: [ 30 | { 31 | text: 'Getting Started', 32 | items: [ 33 | { text: 'Introduction', link: '/' }, 34 | { text: 'Installation', link: '/getting-started/installation' }, 35 | ] 36 | }, 37 | { 38 | text: 'Guide', 39 | items: [ 40 | { text: 'Defining factories', link: '/guide/defining-factories' }, 41 | { text: 'Using factories', link: '/guide/using-factories' }, 42 | ] 43 | }, 44 | { 45 | text: 'Integrations', 46 | items: [ 47 | { text: 'Japa', link: '/integrations/japa' }, 48 | ] 49 | }, 50 | ], 51 | 52 | algolia: { 53 | appId: 'E5R26D6CBG', 54 | apiKey: '7b5cac7aac51ad74667bb0ecbc376f8e', 55 | indexName: 'factorify', 56 | }, 57 | 58 | socialLinks: [ 59 | { icon: 'github', link: 'https://github.com/julien-r44/factorify/' }, 60 | { icon: 'twitter', link: 'https://twitter.com/julien_rpt' } 61 | ], 62 | 63 | 64 | editLink: { 65 | text: 'Edit this page on GitHub', 66 | pattern: 'https://github.com/Julien-R44/factorify/tree/main/docs/:path' 67 | }, 68 | 69 | outline: 'deep' 70 | } 71 | }) 72 | -------------------------------------------------------------------------------- /docs/integrations/japa.md: -------------------------------------------------------------------------------- 1 | # Japa integration 2 | 3 | [Japa](https://japa.dev) is a test runner that focuses only on testing backend applications and library written for the Node.js runtime. It is developped by the [Adonis.js](https://adonisjs.com/) team. 4 | 5 | We provide an integration plugin for Japa, that you can install as follows : 6 | 7 | ```bash 8 | pnpm add -D @julr/japa-factorify-plugin 9 | ``` 10 | 11 | ## Usage 12 | 13 | Once the plugin is installed, you must add the plugin in your Japa configure as follows : 14 | 15 | ```ts 16 | // bin/test.ts 17 | import { factorify } from '@julr/japa-factorify-plugin' 18 | 19 | configure({ 20 | ...processCliArgs(process.argv.slice(2)), 21 | ...{ 22 | // ... 23 | plugins: [ 24 | factorify({ 25 | database: { 26 | // See https://knexjs.org/guide/#configuration-options 27 | // for more information 28 | connection: { 29 | host: 'localhost', 30 | user: 'root', 31 | password: 'password', 32 | database: 'factorify', 33 | } 34 | }, 35 | }), 36 | ], 37 | }, 38 | }) 39 | 40 | // ... 41 | ``` 42 | Since Factorify is built on top of Knex, see more information about the database configuration [here](https://knexjs.org/guide/#configuration-options). 43 | 44 | The plugin will automatically open, and close the database connection after the tests are done. 45 | 46 | ## @julr/japa-database-plugin 47 | 48 | I recommend you to also use the [@julr/japa-database-plugin](https://github.com/Julien-R44/japa-database-plugin) that extend the Japa matchers with new assertions for the database. Also provide an utility function that allow you to refresh your database between each test : 49 | 50 | ```ts{5-6,16-18} 51 | import { test } from '@japa/runner' 52 | import { DatabaseUtils } from '@julr/japa-database-plugin' 53 | 54 | test.group('My group', group => { 55 | // 👇 Refresh the database between each test 56 | group.each.setup(() => DatabaseUtils.refreshDatabase()) 57 | 58 | test('Should return user', async ({ database, client }) => { 59 | const user = await UserFactory.merge({ email: 'test@factorify.com' }).create() 60 | 61 | const response = await client.get('/users') 62 | 63 | response.assertStatus(200) 64 | response.assertBody([user]) 65 | 66 | // 👇 Simple assertions on the database. 67 | await database.assertCount('users', 1) 68 | await database.assertHas('users', { email: 'test@factorify.com' }) 69 | }) 70 | }) 71 | ``` 72 | 73 | 74 | 75 | More information here : https://github.com/Julien-R44/japa-database-plugin 76 | -------------------------------------------------------------------------------- /tests-helpers/db.ts: -------------------------------------------------------------------------------- 1 | import knex from 'knex' 2 | import type { Knex } from 'knex' 3 | 4 | const credentials = { 5 | host: 'localhost', 6 | user: 'japa', 7 | password: 'password', 8 | database: 'japa', 9 | } 10 | 11 | const connectionConfigMap: { [key: string]: Knex.Config } = { 12 | better_sqlite: { client: 'better-sqlite3', connection: { filename: 'test.sqlite' } }, 13 | sqlite: { client: 'sqlite', connection: { filename: 'test' } }, 14 | mysql: { client: 'mysql2', connection: { ...credentials, port: 3306 } }, 15 | postgres: { client: 'pg', connection: { ...credentials, port: 5432 } }, 16 | mssql: { 17 | client: 'mssql', 18 | connection: { 19 | host: 'localhost', 20 | database: 'master', 21 | user: 'sa', 22 | password: 'arandom&233password', 23 | port: 1433, 24 | }, 25 | }, 26 | } 27 | 28 | export const connectionConfig = connectionConfigMap[process.env.DB || 'better_sqlite']! 29 | 30 | export const connection = knex(connectionConfig) 31 | 32 | export const setupDb = async () => { 33 | await connection.schema.dropTableIfExists('profile') 34 | await connection.schema.dropTableIfExists('post') 35 | await connection.schema.dropTableIfExists('account') 36 | await connection.schema.dropTableIfExists('user') 37 | await connection.schema.dropTableIfExists('admin') 38 | 39 | await connection.schema.createTable('user', (table) => { 40 | table.increments('id').primary() 41 | table.string('email') 42 | table.string('password') 43 | }) 44 | 45 | await connection.schema.createTable('admin', (table) => { 46 | table.increments('id').primary() 47 | table.string('email') 48 | table.string('password') 49 | }) 50 | 51 | await connection.schema.createTable('profile', (table) => { 52 | table.increments('id').primary() 53 | table.string('email') 54 | table.integer('age') 55 | table.integer('user_id').unsigned() 56 | 57 | table.foreign('user_id').references('id').inTable('user').onDelete('CASCADE') 58 | }) 59 | 60 | await connection.schema.createTable('post', (table) => { 61 | table.increments('id').primary() 62 | table.string('title') 63 | table.integer('user_id').unsigned() 64 | 65 | table.foreign('user_id').references('id').inTable('user').onDelete('CASCADE') 66 | }) 67 | 68 | await connection.schema.createTable('account', (table) => { 69 | table.increments('id').primary() 70 | table.string('name') 71 | table.integer('user_id').unsigned() 72 | table.integer('admin_id').unsigned().nullable() 73 | 74 | table.foreign('user_id').references('id').inTable('user').onDelete('CASCADE') 75 | table.foreign('admin_id').references('id').inTable('admin').onDelete('CASCADE') 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Install the package from npm: 4 | 5 | ```sh 6 | pnpm add -D @julr/factorify 7 | 8 | npm install --save-dev @julr/factorify 9 | 10 | yarn add -D @julr/factorify 11 | ``` 12 | 13 | ## Usage example 14 | 15 | ::: tip 16 | Before starting, make sure to check the integrations available. Maybe we are providing a plugin for your test runner. 17 | ::: 18 | 19 | Before running your tests, you must initialize Factorify with your database configuration. 20 | 21 | This must be done **BEFORE** creating models via Factorify. In general, you can use the setup files, or hooks system provided by your test runner. 22 | 23 | ```ts 24 | import { defineFactorifyConfig } from '@julr/factorify' 25 | 26 | // Make sure that piece of code is executed before the tests are run 27 | const disconnect = defineFactorifyConfig({ 28 | database: { 29 | // See https://knexjs.org/guide/#configuration-options 30 | // for more information about the possible options 31 | client: 'sqlite3', 32 | connection: { 33 | host: 'localhost', 34 | user: 'root', 35 | password: 'password', 36 | database: 'factorify', 37 | } 38 | }, 39 | }) 40 | 41 | // Once you are done with the tests, you must close the database connection by calling the disconnect function returned by `defineFactorifyConfig` 42 | // For example, in Jest, you can do this in a `afterAll` hook 43 | afterAll(() => disconnect()) 44 | ``` 45 | 46 | `defineFactorifyConfig` returns a function that can be used to disconnect from the database. 47 | 48 | This is useful when you want to cleanly disconnect from the database after all tests have been run. 49 | 50 | > Note: You don't need to do this manually if you are using a test runner integration. 51 | 52 | 53 | If you are struggling to integrate Factorify with your test runner, feel free to open an issue on the [GitHub repository](https://github.com/Julien-R44/factorify/issues). I will be happy to provide some examples. 54 | 55 | ## Configuration 56 | 57 | ### Casing Strategy 58 | 59 | You can also define a specific casing strategy. By default, Factorify convert all keys to `snake_case` before inserting the models into the database. And before returning the model, it converts all keys to `camelCase`. 60 | 61 | ```ts 62 | import { defineFactorifyConfig } from '@julr/factorify' 63 | 64 | defineFactorifyConfig({ 65 | casing: { 66 | // Convert all keys to snake_case before inserting into the database 67 | insert: 'snake', 68 | 69 | // Convert all keys to camelCase before returning the models 70 | return: 'camel', 71 | } 72 | }) 73 | ``` 74 | 75 | ### Faker Locale 76 | 77 | You can also define a specific locale for Faker. By default, Factorify uses the `en` locale. 78 | 79 | ```ts{4} 80 | import { defineFactorifyConfig } from '@julr/factorify' 81 | 82 | defineFactorifyConfig({ 83 | locale: 'fr', 84 | }) 85 | ``` 86 | 87 | -------------------------------------------------------------------------------- /packages/core/src/model.ts: -------------------------------------------------------------------------------- 1 | import { Builder } from './builder/builder' 2 | import { RelationType } from './contracts' 3 | import type { 4 | DefineFactoryCallback, 5 | DefineStateCallback, 6 | RelationshipMeta, 7 | RelationshipMetaOptions, 8 | } from './contracts' 9 | 10 | export class FactoryModel< 11 | Model extends Record, 12 | States extends string | null = null, 13 | Relationships extends string | null = null 14 | > { 15 | /** 16 | * Store the factory callback 17 | */ 18 | public callback: DefineFactoryCallback 19 | 20 | /** 21 | * Store each state callbacks 22 | */ 23 | public states: Record> = {} 24 | 25 | /** 26 | * Store relations metadata 27 | */ 28 | public relations: Record = {} 29 | 30 | /** 31 | * The SQL table name for the model. 32 | */ 33 | public tableName: string 34 | 35 | constructor(tableName: string, callback: DefineFactoryCallback) { 36 | this.tableName = tableName 37 | this.callback = callback 38 | } 39 | 40 | private addRelation( 41 | name: string, 42 | factory: RelationshipMeta['factory'], 43 | type: RelationType, 44 | meta?: RelationshipMetaOptions 45 | ) { 46 | const foreignKey = type === RelationType.BelongsTo ? `${name}_id` : `${this.tableName}_id` 47 | this.relations[name] = { 48 | foreignKey, 49 | localKey: 'id', 50 | factory, 51 | ...meta, 52 | type, 53 | } 54 | 55 | return this 56 | } 57 | 58 | /** 59 | * Allows you to define a new state for the factory. 60 | */ 61 | public state( 62 | name: S, 63 | stateCb: DefineStateCallback 64 | ): FactoryModel> { 65 | this.states[name] = stateCb 66 | return this 67 | } 68 | 69 | /** 70 | * Add hasOne relationship 71 | */ 72 | public hasOne( 73 | name: S, 74 | cb: RelationshipMeta['factory'], 75 | meta?: RelationshipMetaOptions 76 | ): FactoryModel> { 77 | return this.addRelation(name, cb, RelationType.HasOne, meta) 78 | } 79 | 80 | /** 81 | * Add hasMany relationship 82 | */ 83 | public hasMany( 84 | name: S, 85 | cb: RelationshipMeta['factory'], 86 | meta?: RelationshipMetaOptions 87 | ): FactoryModel> { 88 | return this.addRelation(name, cb, RelationType.HasMany, meta) 89 | } 90 | 91 | /** 92 | * Add belongsTo relationship 93 | */ 94 | public belongsTo( 95 | name: S, 96 | cb: RelationshipMeta['factory'], 97 | meta?: RelationshipMetaOptions 98 | ): FactoryModel> { 99 | return this.addRelation(name, cb, RelationType.BelongsTo, meta) 100 | } 101 | 102 | /** 103 | * Returns the Builder 104 | */ 105 | public build(): Builder { 106 | return new Builder(this) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.bunny.net/css?family=inter:400,500,600,700); 2 | 3 | .dark .VPNav .content { 4 | background-color: var(--background-color) !important; 5 | } 6 | 7 | .VPSidebar { 8 | width: auto !important; 9 | padding-left: 32px !important; 10 | } 11 | 12 | .container { 13 | max-width: none !important; 14 | } 15 | 16 | img { 17 | border-radius: 7px; 18 | } 19 | 20 | /** 21 | * Colors Theme 22 | * -------------------------------------------------------------------------- */ 23 | 24 | :root { 25 | --background-color: #191919; 26 | } 27 | 28 | :root { 29 | --vp-c-bg: var(--vp-c-white); 30 | --vp-c-bg-soft: var(--vp-c-white-soft); 31 | --vp-c-bg-mute: var(--vp-c-white-mute); 32 | --vp-c-bg-alt: var(--vp-c-white-soft); 33 | 34 | --vp-c-divider: var(--vp-c-divider-light-1); 35 | --vp-c-divider-light: var(--vp-c-divider-light-2); 36 | 37 | --vp-c-divider-inverse: var(--vp-c-divider-dark-1); 38 | --vp-c-divider-inverse-light: var(--vp-c-divider-dark-2); 39 | 40 | --vp-c-text-1: var(--vp-c-text-light-1); 41 | --vp-c-text-2: var(--vp-c-text-light-2); 42 | --vp-c-text-3: var(--vp-c-text-light-3); 43 | --vp-c-text-4: var(--vp-c-text-light-4); 44 | 45 | --vp-c-text-inverse-1: var(--vp-c-text-dark-1); 46 | --vp-c-text-inverse-2: var(--vp-c-text-dark-2); 47 | --vp-c-text-inverse-3: var(--vp-c-text-dark-3); 48 | --vp-c-text-inverse-4: var(--vp-c-text-dark-4); 49 | 50 | --vp-c-text-code: var(--vp-c-indigo-soft); 51 | 52 | --vp-c-brand: #ff8a00; 53 | --vp-c-brand-light: #ff8a00; 54 | --vp-c-brand-lighter: #ff8a00; 55 | --vp-c-brand-dark: #ff8a00; 56 | --vp-c-brand-darker: #ff8a00; 57 | 58 | --vp-c-sponsor: #fd1d7c; 59 | } 60 | 61 | .dark { 62 | --vp-c-bg: var(--background-color); 63 | --vp-c-bg-soft: var(--background-color); 64 | --vp-c-bg-mute: var(--background-color); 65 | --vp-c-bg-alt: #141414; 66 | 67 | --vp-c-divider: var(--vp-c-divider-dark-1); 68 | --vp-c-divider-light: var(--vp-c-divider-dark-2); 69 | 70 | --vp-c-divider-inverse: var(--vp-c-divider-light-1); 71 | --vp-c-divider-inverse-light: var(--vp-c-divider-light-2); 72 | 73 | --vp-c-text-1: var(--vp-c-text-dark-1); 74 | --vp-c-text-2: var(--vp-c-text-dark-2); 75 | --vp-c-text-3: var(--vp-c-text-dark-3); 76 | --vp-c-text-4: var(--vp-c-text-dark-4); 77 | 78 | --vp-c-text-inverse-1: var(--vp-c-text-light-1); 79 | --vp-c-text-inverse-2: var(--vp-c-text-light-2); 80 | --vp-c-text-inverse-3: var(--vp-c-text-light-3); 81 | --vp-c-text-inverse-4: var(--vp-c-text-light-4); 82 | 83 | --vp-c-text-code: var(--vp-c-indigo-lighter); 84 | } 85 | 86 | /** 87 | * Typography 88 | * -------------------------------------------------------------------------- */ 89 | 90 | :root { 91 | --vp-font-family-base: 'Inter', 'Inter var experimental', 'Inter var', ui-sans-serif, 92 | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 93 | 'Helvetica Neue', Helvetica, Arial, 'Noto Sans', sans-serif, 94 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 95 | --vp-font-family-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 96 | Consolas, 'Liberation Mono', 'Courier New', monospace; 97 | } 98 | -------------------------------------------------------------------------------- /packages/core/src/contracts.ts: -------------------------------------------------------------------------------- 1 | import type { Builder } from './builder/builder' 2 | import type { FactoryModel } from './model' 3 | import type { faker } from '@faker-js/faker' 4 | import type { Knex } from 'knex' 5 | 6 | type Optional = Pick, K> & Omit 7 | 8 | export type CasingStrategy = 'camel' | 'snake' | 'none' 9 | 10 | /** 11 | * Callback that must be passed to the `defineFactory` function. 12 | */ 13 | export type DefineFactoryCallback = (args: { faker: typeof faker; isStubbed: boolean }) => { 14 | [K in keyof T]: T[K] | (() => T[K] | Promise) 15 | } 16 | 17 | /** 18 | * Callback that must be passed to the `state` function. 19 | */ 20 | export type DefineStateCallback = (attributes: T) => Partial 21 | 22 | /** 23 | * The Factorify configuration. 24 | */ 25 | export interface FactorifyConfig { 26 | database: Knex.Config 27 | 28 | /** 29 | * Configure the casing conversion for the database operations 30 | */ 31 | casing?: { 32 | /** 33 | * Casing to which the keys will be converted before inserting into the database 34 | * 35 | * Default: `snake` 36 | */ 37 | insert: CasingStrategy 38 | 39 | /** 40 | * Casing to which the keys will be converted before returning 41 | * 42 | * Default: `camel` 43 | */ 44 | return: CasingStrategy 45 | } 46 | } 47 | 48 | /** 49 | * Extract generics from FactoryModel class 50 | */ 51 | export type FactoryExtractGeneric< 52 | Factory extends FactoryModel, 53 | Extracted extends 'states' | 'model' | 'relationships' 54 | > = Factory extends FactoryModel 55 | ? Extracted extends 'states' 56 | ? States 57 | : Extracted extends 'model' 58 | ? Model 59 | : Extracted extends 'relationships' 60 | ? Relationships 61 | : never 62 | : never 63 | 64 | /** 65 | * Callback that must be passed to the `with` function. 66 | */ 67 | export type WithCallback = (builder: Builder) => void 68 | 69 | /** 70 | * Possible relations type 71 | */ 72 | export enum RelationType { 73 | HasOne = 'has-one', 74 | HasMany = 'has-many', 75 | BelongsTo = 'belongs-to', 76 | } 77 | 78 | /** 79 | * Metadata for a relationship. 80 | */ 81 | export interface RelationshipMeta { 82 | type: RelationType 83 | 84 | /** 85 | * If no localKey is defined, we gonna assume that it's "id" 86 | */ 87 | localKey: string 88 | 89 | /** 90 | * If no foreignKey is defined, we gonna assume that it's "{tableName}_id" 91 | */ 92 | foreignKey: string 93 | 94 | /** 95 | * Reference to the relation factory 96 | * 97 | * Note: Type is any because otherwise the circular dependency betweens 98 | * two factories that are cross-referenced would totally break the type system. 99 | * 100 | * I don't know how to solve this problem yet. If you come up with a solution, 101 | * or any ideas, please open a issue. Would be awesome to have this ! 102 | */ 103 | factory: () => Builder 104 | } 105 | 106 | export type RelationshipMetaOptions = Optional< 107 | Omit, 108 | 'foreignKey' | 'localKey' 109 | > 110 | -------------------------------------------------------------------------------- /tests/has_one.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | import { DatabaseUtils } from '@julr/japa-database-plugin' 3 | import { UserFactory } from '../tests-helpers/setup.js' 4 | import { setupDb } from '../tests-helpers/db.js' 5 | 6 | test.group('HasOne', (group) => { 7 | group.setup(async () => setupDb()) 8 | group.each.setup(() => DatabaseUtils.refreshDatabase()) 9 | 10 | test('Basic', async ({ database }) => { 11 | const user = await UserFactory.with('profile').create() 12 | 13 | await database.assertCount('user', 1) 14 | await database.assertCount('profile', 1) 15 | 16 | await database.assertHas('profile', { user_id: user.id }, 1) 17 | }) 18 | 19 | test('createMany', async ({ database }) => { 20 | await database.assertCount('user', 0) 21 | await database.assertCount('profile', 0) 22 | 23 | const users = await UserFactory.with('profile').createMany(2) 24 | 25 | await database.assertCount('user', 2) 26 | await database.assertCount('profile', 2) 27 | 28 | await database.assertHas('profile', { user_id: users[0].id }, 1) 29 | await database.assertHas('profile', { user_id: users[1].id }, 1) 30 | }) 31 | 32 | test('with overloading', async ({ database }) => { 33 | const user = await UserFactory.with('profile', (profile) => 34 | profile.merge({ email: 'hey@ok.com' }) 35 | ).create() 36 | 37 | await database.assertCount('user', 1) 38 | await database.assertCount('profile', 1) 39 | 40 | await database.assertHas('profile', { user_id: user.id, email: 'hey@ok.com' }, 1) 41 | }) 42 | 43 | test('returns relationship', async ({ expect }) => { 44 | const user = await UserFactory.with('profile').create() 45 | 46 | expect(user.profile).toBeDefined() 47 | expect(user.profile.age).toBeDefined() 48 | }) 49 | 50 | test('returns relationship - createMany', async ({ expect }) => { 51 | const users = await UserFactory.with('profile').createMany(2) 52 | 53 | expect(users[0].profile).toBeDefined() 54 | expect(users[0].profile.age).toBeDefined() 55 | expect(users[0].id).toStrictEqual(users[0].profile.userId) 56 | 57 | expect(users[1].profile).toBeDefined() 58 | expect(users[1].profile.age).toBeDefined() 59 | expect(users[1].id).toStrictEqual(users[1].profile.userId) 60 | }) 61 | 62 | test('Chaining with', async ({ database }) => { 63 | const user = await UserFactory.with('profile').with('profile').create() 64 | 65 | await database.assertCount('user', 1) 66 | await database.assertCount('profile', 2) 67 | 68 | await database.assertHas('profile', { user_id: user.id }, 2) 69 | }) 70 | 71 | test('Chaining with - callback', async ({ database }) => { 72 | const user = await UserFactory.with('profile', 1, (profile) => profile.merge({ age: 20 })) 73 | .with('profile', 1, (profile) => profile.merge({ age: 30 })) 74 | .create() 75 | 76 | await database.assertCount('user', 1) 77 | await database.assertCount('profile', 2) 78 | 79 | await database.assertHas('profile', { user_id: user.id, age: 20 }, 1) 80 | await database.assertHas('profile', { user_id: user.id, age: 30 }, 1) 81 | }) 82 | 83 | test('With - state', async ({ database }) => { 84 | const user = await UserFactory.with('profile', 1, (profile) => 85 | profile.apply('old').apply('admin') 86 | ).create() 87 | 88 | await database.assertCount('user', 1) 89 | await database.assertCount('profile', 1) 90 | 91 | await database.assertHas('profile', { user_id: user.id, age: 150, email: 'admin@admin.com' }, 1) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /docs/guide/using-factories.md: -------------------------------------------------------------------------------- 1 | # Using Factories 2 | 3 | Once your factory is defined, your database is configured, you can start using your factories. 4 | 5 | ```ts 6 | import { UserFactory } from './my-factory.js' 7 | 8 | const user = await UserFactory.create() 9 | const users = await UserFactory.createMany(10) 10 | ``` 11 | 12 | Note that the `create` and `createMany` methods are asynchronous. This is because they are using the database to create the records. 13 | 14 | Also note that the `create` and `createMany` methods are returning the created records. This is useful if you want to use the created records in your tests. 15 | 16 | ## Merge attributes 17 | 18 | You can override the default set of attributes using the .merge method. For example: 19 | 20 | ```ts 21 | await UserFactory 22 | .merge({ email: 'test@example.com' }) 23 | .create() 24 | ``` 25 | 26 | When creating multiple instances, you can define an array of attributes and they will merge based upon their indices. For example: 27 | 28 | ```ts 29 | await UserFactory 30 | .merge([ 31 | { email: 'foo@example.com' }, 32 | { email: 'bar@example.com' }, 33 | ]) 34 | .createMany(3) 35 | ``` 36 | 37 | In the above example 38 | 39 | - The first user will have the email of `foo@example.com`. 40 | - The second user will have the email of `bar@example.com`. 41 | - And, the third user will use the default email address, since the merge array has a length of 2. 42 | 43 | ## Applying states 44 | 45 | For applying a defined state, you can use the `.apply` method. For example: 46 | 47 | ```ts 48 | await PostFactory.apply('published').createMany(3) 49 | await PostFactory.createMany(3) 50 | ``` 51 | 52 | ## Relationships 53 | 54 | Make sur to define your relationship on your factories. See [Defining Factories](./defining-factories.md#relationships) for more information. 55 | 56 | Once done, you can use the `.with` method to create a model with its relationships. For example: 57 | 58 | ```ts 59 | const userWithPost = await UserFactory.with('posts', 3).create() 60 | 61 | console.log(user.posts.length) // 3 62 | console.log(user.posts[0].userId === user.id) // true 63 | ``` 64 | 65 | ### States and merge on relationships 66 | 67 | ### Applying relationship states 68 | 69 | You can also apply states on a relationship by passing a callback to the with method. 70 | 71 | ```ts 72 | const user = await UserFactory 73 | .with('posts', 3, (post) => post.apply('published')) 74 | .create() 75 | ``` 76 | 77 | Similarly, if you want, you can create few posts with the published state and few without it. 78 | 79 | ```ts 80 | const user = await UserFactory 81 | .with('posts', 3, (post) => post.apply('published')) 82 | .with('posts', 2) 83 | .create() 84 | 85 | user.posts.length // 5 86 | ``` 87 | 88 | Finally, you can also create nested relationships. For example: Create a user with two posts and five comments for each post. 89 | 90 | ```ts 91 | const user = await UserFactory 92 | .with('posts', 2, (post) => post.with('comments', 5)) 93 | .create() 94 | ``` 95 | 96 | ## Stubbing models 97 | 98 | In some cases, you may prefer to stub out the database calls and just want to create in-memory model instances. This is can achieved using the `make` and `makeMany` methods. 99 | 100 | ```ts 101 | const user = await UserFactory 102 | .with('posts', 2) 103 | .make() 104 | 105 | console.log(user.id) 106 | ``` 107 | 108 | The `make` calls will never hit the database and will assign an in-memory numeric id to the model instances. 109 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@julr/factorify-monorepo", 3 | "type": "module", 4 | "version": "1.0.0-beta.4", 5 | "packageManager": "pnpm@8.14.1", 6 | "description": "", 7 | "author": "Julien Ripouteau ", 8 | "license": "MIT", 9 | "funding": "https://github.com/sponsors/Julien-R44", 10 | "homepage": "https://github.com/Julien-R44/factorify#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/Julien-R44/factorify.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/Julien-R44/factorify/issues" 17 | }, 18 | "keywords": [], 19 | "sideEffects": false, 20 | "exports": { 21 | ".": { 22 | "types": "./dist/index.d.ts", 23 | "require": "./dist/index.cjs", 24 | "import": "./dist/index.mjs" 25 | } 26 | }, 27 | "main": "./dist/index.cjs", 28 | "module": "./dist/index.mjs", 29 | "types": "./dist/index.d.ts", 30 | "typesVersions": { 31 | "*": { 32 | "*": [ 33 | "./dist/*", 34 | "./dist/index.d.ts" 35 | ] 36 | } 37 | }, 38 | "files": [ 39 | "dist" 40 | ], 41 | "engines": { 42 | "node": ">=18" 43 | }, 44 | "publishConfig": { 45 | "access": "public" 46 | }, 47 | "scripts": { 48 | "dev": "unbuild --stub", 49 | "lint": "eslint . --ext=.ts", 50 | "format": "prettier --write .", 51 | "prepublishOnly": "pnpm build", 52 | "release": "bumpp packages/*/package.json --commit \"chore(release): %s\" --push --tag && pnpm -r publish --access public", 53 | "stub": "pnpm -r --filter=./packages/* --parallel run stub", 54 | "build": "pnpm -r --filter=./packages/* run build", 55 | "test:coverage": "c8 pnpm test", 56 | "test": "ts-node-esm bin/test.ts", 57 | "test:sqlite": "cross-env DB=sqlite pnpm test", 58 | "test:better_sqlite": "cross-env DB=better_sqlite pnpm test", 59 | "test:postgres": "cross-env DB=postgres pnpm test", 60 | "test:mysql": "cross-env DB=mysql pnpm test", 61 | "test:mssql": "cross-env DB=mssql pnpm test", 62 | "test:all": "pnpm test:sqlite && pnpm test:postgres && pnpm test:mysql", 63 | "typecheck": "tsc --noEmit" 64 | }, 65 | "devDependencies": { 66 | "@japa/assert": "^1.3.6", 67 | "@japa/expect": "^2.0.1", 68 | "@japa/runner": "^2.2.1", 69 | "@japa/spec-reporter": "^1.3.1", 70 | "@julr/eslint-config": "^0.3.2", 71 | "@julr/factorify": "link:packages\\core", 72 | "@julr/japa-database-plugin": "^1.0.4", 73 | "@julr/japa-factorify-plugin": "link:packages\\japa-plugin", 74 | "@types/node": "^18.7.18", 75 | "better-sqlite3": "^7.6.2", 76 | "bumpp": "^8.2.1", 77 | "c8": "^7.12.0", 78 | "cross-env": "^7.0.3", 79 | "eslint": "^8.23.1", 80 | "knex": "^2.3.0", 81 | "mysql2": "^2.3.3", 82 | "nodemon": "^2.0.20", 83 | "pg": "^8.8.0", 84 | "pnpm": "^8.14.1", 85 | "prettier": "^2.7.1", 86 | "sqlite3": "^5.1.1", 87 | "tedious": "^15.1.0", 88 | "ts-node": "^10.9.1", 89 | "typescript": "^4.8.3", 90 | "unbuild": "^0.8.11" 91 | }, 92 | "pnpm": { 93 | "peerDependencyRules": { 94 | "ignoreMissing": [ 95 | "openapi-types", 96 | "@babel/core", 97 | "@japa/core" 98 | ] 99 | } 100 | }, 101 | "eslintConfig": { 102 | "extends": "@julr" 103 | }, 104 | "prettier": { 105 | "trailingComma": "es5", 106 | "semi": false, 107 | "singleQuote": true, 108 | "useTabs": false, 109 | "quoteProps": "consistent", 110 | "bracketSpacing": true, 111 | "arrowParens": "always", 112 | "printWidth": 100 113 | }, 114 | "nodemonConfig": { 115 | "watch": [ 116 | "src", 117 | "tests" 118 | ], 119 | "execMap": { 120 | "ts": "ts-node-esm" 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/belongs_to.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | import { DatabaseUtils } from '@julr/japa-database-plugin' 3 | import { AccountFactory } from '../tests-helpers/setup.js' 4 | import { setupDb } from '../tests-helpers/db.js' 5 | 6 | test.group('BelongsTo', (group) => { 7 | group.setup(async () => setupDb()) 8 | group.each.setup(() => DatabaseUtils.refreshDatabase()) 9 | 10 | test('Basic', async ({ database, expect }) => { 11 | const account = await AccountFactory.with('user').create() 12 | 13 | expect(account.userId).toBeDefined() 14 | expect(account.user).toBeDefined() 15 | expect(account.user.id).toBeDefined() 16 | expect(account.user.id).toStrictEqual(account.userId) 17 | 18 | await database.assertHas('account', { user_id: account.user.id }, 1) 19 | await database.assertHas('user', { id: account.userId }, 1) 20 | }) 21 | 22 | test('Basic stubbed', async ({ database, expect }) => { 23 | const account = await AccountFactory.with('user').make() 24 | 25 | expect(account.userId).toBeDefined() 26 | expect(account.user).toBeDefined() 27 | expect(account.user.id).toBeDefined() 28 | expect(account.user.id).toStrictEqual(account.userId) 29 | 30 | await database.assertCount('account', 0) 31 | await database.assertCount('user', 0) 32 | }) 33 | 34 | test('createMany', async ({ database }) => { 35 | const [accountA, accountB] = await AccountFactory.with('user').createMany(2) 36 | 37 | await Promise.all([ 38 | await database.assertCount('user', 2), 39 | await database.assertCount('account', 2), 40 | 41 | await database.assertHas('account', { user_id: accountA!.user.id }, 1), 42 | await database.assertHas('account', { user_id: accountB!.user.id }, 1), 43 | 44 | await database.assertHas('user', { id: accountA!.user.id }, 1), 45 | await database.assertHas('user', { id: accountB!.user.id }, 1), 46 | ]) 47 | }) 48 | 49 | test('createMany stubbed', async ({ database, expect }) => { 50 | const [accountA, accountB] = await AccountFactory.with('user').makeMany(2) 51 | 52 | await database.assertCount('user', 0) 53 | await database.assertCount('account', 0) 54 | 55 | expect(accountA!.userId).toBeDefined() 56 | expect(accountB!.userId).toBeDefined() 57 | 58 | expect(accountA!.user.id).toEqual(accountA!.userId) 59 | expect(accountB!.user.id).toEqual(accountB!.userId) 60 | }) 61 | 62 | test('Chaining with', async ({ database }) => { 63 | const account = await AccountFactory.with('user').with('admin').create() 64 | 65 | await Promise.all([ 66 | database.assertCount('user', 1), 67 | database.assertCount('admin', 1), 68 | database.assertCount('account', 1), 69 | 70 | database.assertHas('account', { user_id: account.user.id }, 1), 71 | database.assertHas('account', { admin_id: account.admin.id }, 1), 72 | database.assertHas('user', { id: account.user.id }, 1), 73 | database.assertHas('admin', { id: account.admin.id }, 1), 74 | ]) 75 | }) 76 | 77 | test('Chaining with - callback', async ({ database }) => { 78 | const account = await AccountFactory.with('user', 1, (user) => user.merge({ email: 'bonjour' })) 79 | .with('admin', 1, (admin) => admin.merge({ email: 'admin' })) 80 | .create() 81 | 82 | await Promise.all([ 83 | database.assertHas('user', { email: 'bonjour' }, 1), 84 | database.assertHas('admin', { email: 'admin' }, 1), 85 | database.assertHas('account', { user_id: account.user.id, admin_id: account.admin.id }, 1), 86 | ]) 87 | }) 88 | 89 | test('With - state', async ({ database }) => { 90 | const account = await AccountFactory.with('user', 1, (user) => 91 | user.apply('easyPassword').apply('easyEmail') 92 | ).create() 93 | 94 | await Promise.all([ 95 | database.assertHas('user', { password: 'easy', email: 'easy@easy.com' }), 96 | database.assertHas('account', { user_id: account.user.id }, 1), 97 | ]) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /tests/has_many.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | import { DatabaseUtils } from '@julr/japa-database-plugin' 3 | import { defineFactory } from '@julr/factorify' 4 | import { UserFactory } from '../tests-helpers/setup.js' 5 | import { setupDb } from '../tests-helpers/db.js' 6 | 7 | test.group('HasMany', (group) => { 8 | group.setup(async () => setupDb()) 9 | group.each.setup(() => DatabaseUtils.refreshDatabase()) 10 | 11 | test('Basic', async ({ database }) => { 12 | const user = await UserFactory.with('posts').create() 13 | 14 | await database.assertCount('user', 1) 15 | await database.assertCount('post', 1) 16 | 17 | await database.assertHas('post', { user_id: user.id }, 1) 18 | }) 19 | 20 | test('Basic stubbed', async ({ database, expect }) => { 21 | const user = await UserFactory.with('posts').make() 22 | 23 | await database.assertCount('user', 0) 24 | await database.assertCount('post', 0) 25 | 26 | expect(user.posts).toHaveLength(1) 27 | expect(user.posts[0].userId).toEqual(user.id) 28 | }) 29 | 30 | test('With many', async ({ database }) => { 31 | const user = await UserFactory.with('posts', 2).create() 32 | 33 | await database.assertCount('user', 1) 34 | await database.assertCount('post', 2) 35 | 36 | await database.assertHas('post', { user_id: user.id }, 2) 37 | }) 38 | 39 | test('Should returns as array', async ({ expect }) => { 40 | const user = await UserFactory.with('posts', 2).create() 41 | 42 | expect(user.posts).toBeInstanceOf(Array) 43 | expect(user.posts.length).toStrictEqual(2) 44 | }) 45 | 46 | test('Create many', async ({ expect, database }) => { 47 | const users = await UserFactory.with('posts', 2).createMany(2) 48 | 49 | await Promise.all([ 50 | database.assertCount('user', 2), 51 | database.assertCount('post', 4), 52 | database.assertHas('post', { user_id: users[0].id }, 2), 53 | database.assertHas('post', { user_id: users[1].id }, 2), 54 | ]) 55 | 56 | expect(users[0].posts).toBeInstanceOf(Array) 57 | expect(users[0].posts.length).toStrictEqual(2) 58 | 59 | expect(users[1].posts).toBeInstanceOf(Array) 60 | expect(users[1].posts.length).toStrictEqual(2) 61 | }) 62 | 63 | test('Chaining', async ({ database }) => { 64 | const user = await UserFactory.with('posts', 5).with('posts', 5).create() 65 | 66 | await database.assertCount('user', 1) 67 | await database.assertCount('post', 10) 68 | 69 | await database.assertHas('post', { user_id: user.id }, 10) 70 | }) 71 | 72 | test('With- state', async ({ database }) => { 73 | const user = await UserFactory.with('posts', 10, (post) => post.apply('nodeArticle')).create() 74 | 75 | await database.assertCount('user', 1) 76 | await database.assertCount('post', 10) 77 | 78 | await database.assertHas('post', { user_id: user.id, title: 'NodeJS' }, 10) 79 | }) 80 | 81 | test('With - merge array', async ({ database }) => { 82 | const user = await UserFactory.with('posts', 10, (post) => 83 | post.merge([{ title: 'Rust' }, { title: 'AdonisJS' }]) 84 | ).create() 85 | 86 | await database.assertCount('user', 1) 87 | await database.assertCount('post', 10) 88 | 89 | await database.assertHas('post', { user_id: user.id, title: 'Rust' }, 1) 90 | await database.assertHas('post', { user_id: user.id, title: 'AdonisJS' }, 1) 91 | }) 92 | 93 | test('auto detect foreign and primary keys', async ({ database }) => { 94 | const postFactory = defineFactory('post', ({ faker }) => ({ 95 | title: faker.company.bs(), 96 | })).build() 97 | 98 | const userFactory = defineFactory('user', ({ faker }) => ({ 99 | email: faker.internet.email(), 100 | password: faker.random.alphaNumeric(6), 101 | })) 102 | .hasMany('post', () => postFactory) 103 | .build() 104 | 105 | const user = await userFactory.with('post', 5).create() 106 | 107 | await database.assertCount('user', 1) 108 | await database.assertCount('post', 5) 109 | await database.assertHas('post', { user_id: user.id }, 5) 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /docs/guide/defining-factories.md: -------------------------------------------------------------------------------- 1 | # Defining Factories 2 | 3 | A general recomendation is to define your factories in a separate folder. If you applications is quite complex, it is also a good idea to split your factories in multiple files. 4 | 5 | Anyway, here is an example of a factory definition: 6 | 7 | ```ts 8 | import type { User } from './types.js' 9 | 10 | const UserFactory = defineFactory('user', ({ faker, isStubbed }) => ({ 11 | email: faker.internet.email(), 12 | password: faker.random.alphaNumeric(6), 13 | computedField: () => { 14 | // You can also use a function to define a field 15 | return 'computed value' 16 | } 17 | })) 18 | .build() 19 | ``` 20 | 21 | Some things to note here : 22 | - The first parameter must be the table name of your model. 23 | - We can pass a generic type to the `defineFactory` function. This is useful to get autocompletion on the model attributes. 24 | - Make sure that your factory return an object with all the required properties by your DB, otherwise it will raise not null exceptions. 25 | - Your factory callback is receiving a `faker` object, which is a [Faker.js](https://fakerjs.dev/guide/) instance. You can use it to generate random data. 26 | 27 | ## States 28 | 29 | Factory states allow you to define variations of your factories as states. This is useful when you want have multiple variations of your model that you can re-use in your tests 30 | 31 | ```ts 32 | const UserFactory = defineFactory('user', ({ faker }) => ({ 33 | email: faker.internet.email(), 34 | password: faker.random.alphaNumeric(6), 35 | role: 'user' 36 | })) 37 | .state('admin', () => ({ role: 'admin' })) 38 | .build() 39 | ``` 40 | 41 | Here, by default, all the users created with the `UserFactory` will have the `role` attribute set to `user`. But we can also create an admin user by using the `admin` state: 42 | 43 | ```ts 44 | const admin = await UserFactory.apply('admin').create() 45 | ``` 46 | 47 | ## Relationships 48 | 49 | Factorify allows you to define relationships between your models. Let's say that we have a `Post` model that has a `userId` attribute. We can define a relationship between the `User` and `Post` models like this: 50 | 51 | ```ts 52 | const PostFactory = defineFactory('post', ({ faker }) => ({ 53 | title: faker.lorem.sentence(), 54 | content: faker.lorem.paragraphs(3) 55 | })) 56 | .build() 57 | 58 | const UserFactory = defineFactory('user', ({ faker }) => ({ 59 | email: faker.internet.email(), 60 | password: faker.random.alphaNumeric(6), 61 | role: 'user' 62 | })) 63 | .state('admin', () => ({ role: 'admin' })) 64 | .hasMany('posts', () => PostFactory) // 👈 65 | .build() 66 | ``` 67 | 68 | Now, you can create a user and its posts all together in one call. 69 | 70 | ```ts 71 | const user = await UserFactory.with('posts', 3).create() 72 | ``` 73 | 74 | The followings are the available relationships that works the same way: 75 | 76 | - `hasOne` 77 | - `hasMany` 78 | - `belongsTo` 79 | - `manyToMany` ( 🚧 coming soon ) 80 | 81 | ### Conventions 82 | 83 | Factorify supposes that the foreign key of the relationship is the name of the table in snake case followed by `_id`. 84 | 85 | For the above example, Factorify suppose that the `Post` model has a `user_id` attribute. 86 | 87 | Factorify will also suppose that the local key is `id`. 88 | 89 | If you want to override this convention, you can pass a second parameter to the relationship function: 90 | 91 | ```ts{13-16} 92 | const PostFactory = defineFactory('post', ({ faker }) => ({ 93 | title: faker.lorem.sentence(), 94 | content: faker.lorem.paragraphs(3) 95 | })) 96 | .build() 97 | 98 | const UserFactory = defineFactory('user', ({ faker }) => ({ 99 | email: faker.internet.email(), 100 | password: faker.random.alphaNumeric(6), 101 | role: 'user' 102 | })) 103 | .state('admin', () => ({ role: 'admin' })) 104 | .hasMany('posts', () => PostFactory, { 105 | localKey: 'my_local_id', 106 | foreignKey: 'my_fk_user_id' 107 | }) 108 | .build() 109 | ``` 110 | 111 | Here, we are saying to Factorify that the local key of User is `my_local_id` and the foreign key of Post is `my_fk_user_id`. 112 | 113 | ### Inline relationships 114 | 115 | You can also create inline relationships. This allow you to always create a associated model when you create the parent model. 116 | 117 | ```ts 118 | const UserFactory = defineFactory('user', ({ faker }) => ({ 119 | email: faker.internet.email(), 120 | password: faker.random.alphaNumeric(6), 121 | })).build() 122 | 123 | const AccountFactory = defineFactory('account', ({ faker }) => ({ 124 | name: faker.company.companyName(), 125 | userId: () => UserFactory.create() 126 | })).build() 127 | ``` 128 | 129 | When you create an account, it will also create a user that will be associated to the account. 130 | -------------------------------------------------------------------------------- /packages/core/src/builder/relationship_builder.ts: -------------------------------------------------------------------------------- 1 | import { RelationType } from '../contracts' 2 | import type { WithCallback } from '../contracts' 3 | import type { FactoryModel } from '../model' 4 | 5 | export class RelationshipBuilder { 6 | constructor(private factory: FactoryModel) {} 7 | 8 | /** 9 | * Relationships to create 10 | */ 11 | private appliedRelationships: { name: string; count?: number; callback?: WithCallback }[] = [] 12 | 13 | /** 14 | * Keep track of models created by the belongsTo relationship 15 | * in order to hydrate after the main model is created. 16 | */ 17 | private preModels: Record[] = [] 18 | 19 | /** 20 | * Hydrate relationships into the models before returning them to 21 | * the user 22 | */ 23 | private hydrateRelationships( 24 | models: Record[], 25 | type: string, 26 | relationship: { name: string; count?: number }, 27 | relations: any[] 28 | ) { 29 | for (const model of models) { 30 | if (type === RelationType.HasOne) { 31 | model[relationship.name] = relations.shift() 32 | } else if (type === RelationType.HasMany) { 33 | model[relationship.name] = relations.splice(0, relationship.count || 1) 34 | } else if (type === RelationType.BelongsTo) { 35 | model[relationship.name] = relations.shift() 36 | } 37 | } 38 | } 39 | 40 | /** 41 | * Filter relationships by their type. 42 | */ 43 | private filterRelationshipsByType(type: 'pre' | 'post') { 44 | return this.appliedRelationships.filter((relationship) => { 45 | const meta = this.factory.relations[relationship.name]! 46 | if (type === 'pre') { 47 | return meta.type === RelationType.BelongsTo 48 | } 49 | 50 | return meta.type !== RelationType.BelongsTo 51 | }) 52 | } 53 | 54 | /** 55 | * Create post relationships ( hasOne, hasMany ), and persist them 56 | */ 57 | public async createPost(models: Record[], stubbed = false) { 58 | const relationships = this.filterRelationshipsByType('post') 59 | 60 | for (const relationship of relationships) { 61 | const { name, count, callback } = relationship 62 | const { factory, foreignKey, localKey, type } = this.factory.relations[name]! 63 | 64 | const factoryRef = factory() 65 | if (callback) callback(factoryRef) 66 | 67 | const mergeAttributes = models.reduce((acc, model) => { 68 | for (let i = 0; i < (count || 1); i++) { 69 | const mergeInput = factoryRef.getMergeAttributes(i) 70 | acc.push({ ...mergeInput, [foreignKey]: model[localKey] }) 71 | } 72 | return acc 73 | }, []) 74 | 75 | const method = stubbed ? 'makeMany' : 'createMany' 76 | const relations = await factoryRef 77 | .merge(mergeAttributes) 78 | [method]((count || 1) * models.length) 79 | 80 | this.hydrateRelationships(models, type, relationship, relations) 81 | } 82 | } 83 | 84 | /** 85 | * Create pre relationships ( belongsTo ), and persist them 86 | */ 87 | public async createPre(models: Record[], stubbed = false) { 88 | const relationships = this.filterRelationshipsByType('pre') 89 | 90 | for (const relationship of relationships) { 91 | const { name, count, callback } = relationship 92 | const { factory, foreignKey, localKey } = this.factory.relations[name]! 93 | 94 | const factoryRef = factory() 95 | 96 | if (callback) callback(factoryRef) 97 | 98 | const method = stubbed ? 'makeMany' : 'createMany' 99 | const relations = await factoryRef[method]((count || 1) * models.length) 100 | models.forEach((model, index) => (model[foreignKey] = relations[index][localKey])) 101 | 102 | this.preModels = this.preModels.concat({ 103 | name, 104 | count, 105 | relations, 106 | }) 107 | } 108 | } 109 | 110 | /** 111 | * Hydrate the pre models into the main models 112 | */ 113 | public postHydrate(models: Record[]) { 114 | for (const { name, count, relations } of this.preModels) { 115 | this.hydrateRelationships(models, 'belongs-to', { name, count }, relations) 116 | } 117 | return models 118 | } 119 | 120 | /** 121 | * Register a relationship to be created 122 | */ 123 | public apply(name: string, count?: number, callback?: WithCallback) { 124 | const relationship = this.factory.relations[name] 125 | 126 | if (!relationship) { 127 | throw new Error(`The relationship "${name}" does not exist on the factory`) 128 | } 129 | 130 | this.appliedRelationships.push({ name, count, callback }) 131 | } 132 | 133 | /** 134 | * Reset the builder to its initial state. 135 | */ 136 | public reset() { 137 | this.appliedRelationships = [] 138 | this.preModels = [] 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /tests/core.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sonarjs/no-duplicate-string */ 2 | import { test } from '@japa/runner' 3 | import { defineFactory } from '@julr/factorify' 4 | import { DatabaseUtils } from '@julr/japa-database-plugin' 5 | import { AccountFactory, UserFactory as BaseUserFactory } from '../tests-helpers/setup.js' 6 | import { setupDb } from '../tests-helpers/db.js' 7 | 8 | const UserFactory = defineFactory('user', ({ faker }) => ({ 9 | email: faker.internet.email(), 10 | password: faker.random.alphaNumeric(6), 11 | })).build() 12 | 13 | test.group('factorify', (group) => { 14 | group.setup(() => setupDb()) 15 | group.each.setup(() => DatabaseUtils.refreshDatabase()) 16 | 17 | test('create one entity', async ({ database }) => { 18 | await UserFactory.create() 19 | await database.assertCount('user', 1) 20 | }) 21 | 22 | test('create one stubbed entity', async ({ database, expect }) => { 23 | const user = await UserFactory.make() 24 | await database.assertCount('user', 0) 25 | 26 | expect(user).toHaveProperty('password') 27 | expect(user).toHaveProperty('email') 28 | }) 29 | 30 | test('create many entities', async ({ database }) => { 31 | await UserFactory.createMany(10) 32 | await database.assertCount('user', 10) 33 | }) 34 | 35 | test('create many stubbed entities', async ({ database, expect }) => { 36 | const users = await UserFactory.makeMany(10) 37 | await database.assertCount('user', 0) 38 | 39 | expect(users).toHaveLength(10) 40 | expect(users[0]).toHaveProperty('password') 41 | expect(users[0]).toHaveProperty('email') 42 | }) 43 | 44 | test('merge with one entity', async ({ database }) => { 45 | await UserFactory.merge({ email: 'bonjour@ok.com' }).create() 46 | await database.assertHas('user', { email: 'bonjour@ok.com' }) 47 | }) 48 | 49 | test('merge one entity stubbed', async ({ database, expect }) => { 50 | const user = await UserFactory.merge({ email: 'bonjour@ok.com' }).make() 51 | await database.assertCount('user', 0) 52 | 53 | expect(user.email).toBe('bonjour@ok.com') 54 | }) 55 | 56 | test('merge many entities', async ({ expect, database }) => { 57 | const users = await UserFactory.merge({ email: 'bonjour' }).createMany(10) 58 | for (const user of users) expect(user.email).toBe('bonjour') 59 | await database.assertCount('user', 10) 60 | }) 61 | 62 | test('merge many entities stubbed', async ({ expect, database }) => { 63 | const users = await UserFactory.merge({ email: 'bonjour' }).makeMany(10) 64 | for (const user of users) expect(user.email).toBe('bonjour') 65 | await database.assertCount('user', 0) 66 | }) 67 | 68 | test('merge many entities with sequence', async ({ database }) => { 69 | await UserFactory.merge([ 70 | { email: 'first@ok.com' }, 71 | { email: 'second@ok.com' }, 72 | { email: 'third@ok.com' }, 73 | ]).createMany(3) 74 | 75 | await database.assertHas('user', { email: 'first@ok.com' }) 76 | await database.assertHas('user', { email: 'second@ok.com' }) 77 | await database.assertHas('user', { email: 'third@ok.com' }) 78 | }) 79 | 80 | test('merge many entities with sequence stubbed', async ({ expect }) => { 81 | const users = await UserFactory.merge([ 82 | { email: 'first@ok.com' }, 83 | { email: 'second@ok.com' }, 84 | { email: 'third@ok.com' }, 85 | ]).createMany(3) 86 | 87 | expect(users).toHaveLength(3) 88 | expect(users[0].email).toBe('first@ok.com') 89 | expect(users[1].email).toBe('second@ok.com') 90 | expect(users[2].email).toBe('third@ok.com') 91 | }) 92 | 93 | test('createMany should return keys camelized', async ({ expect }) => { 94 | const users = await UserFactory.merge([ 95 | { email: 'first@ok.com' }, 96 | { email: 'second@ok.com' }, 97 | { email: 'third@ok.com' }, 98 | ]).createMany(3) 99 | 100 | expect(users.length).toBe(3) 101 | 102 | for (const user of users) { 103 | expect(Object.keys(user)).toEqual(expect.arrayContaining(['id', 'email', 'password'])) 104 | } 105 | }) 106 | 107 | test('create entity with nested inline relationship', async ({ expect, database }) => { 108 | const userFactory = defineFactory('user', ({ faker }) => ({ 109 | email: faker.internet.email(), 110 | password: faker.random.alphaNumeric(6), 111 | })).build() 112 | 113 | const postFactory = defineFactory('post', ({ faker }) => ({ 114 | title: faker.company.bs(), 115 | userId: () => userFactory.create(), 116 | })).build() 117 | 118 | const post = await postFactory.create() 119 | 120 | expect(post.userId).toBeTruthy() 121 | await database.assertCount('user', 1) 122 | await database.assertCount('post', 1) 123 | }) 124 | 125 | test('factory with state', async ({ database }) => { 126 | const userFactory = defineFactory('user', ({ faker }) => ({ 127 | email: 'bonjour', 128 | password: faker.random.alphaNumeric(6), 129 | })) 130 | .state('businessUser', (attributes) => ({ 131 | email: 'business@admin.com', 132 | password: attributes.email, 133 | })) 134 | .state('admin', () => ({ 135 | email: 'admin@admin.admin', 136 | password: 'topsecret', 137 | })) 138 | .build() 139 | 140 | await userFactory.apply('businessUser').create() 141 | await database.assertHas('user', { email: 'business@admin.com', password: 'bonjour' }) 142 | 143 | await userFactory.apply('admin').create() 144 | await database.assertHas('user', { email: 'admin@admin.admin', password: 'topsecret' }) 145 | }) 146 | 147 | test('cross reference', async ({ database }) => { 148 | const account = await AccountFactory.with('user').create() 149 | 150 | await database.assertHas('user', { id: account.userId }) 151 | await database.assertHas('account', { id: account.id }) 152 | 153 | const user = await BaseUserFactory.with('account').create() 154 | 155 | await database.assertHas('user', { id: user.id }) 156 | await database.assertHas('account', { id: user.account.id }) 157 | 158 | await database.assertCount('user', 2) 159 | await database.assertCount('account', 2) 160 | }) 161 | }) 162 | -------------------------------------------------------------------------------- /packages/core/src/builder/builder.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import defu from 'defu' 3 | import { factorifyConfig } from '../config' 4 | import { convertCase } from '../utils' 5 | import { RelationshipBuilder } from './relationship_builder' 6 | import { StatesManager } from './states_manager' 7 | import type { FactoryModel } from '../model' 8 | import type { FactoryExtractGeneric, WithCallback } from '../contracts' 9 | import type { Knex } from 'knex' 10 | 11 | export class Builder< 12 | Factory extends FactoryModel, 13 | Model extends Record = FactoryExtractGeneric, 14 | States = FactoryExtractGeneric, 15 | Relationships = FactoryExtractGeneric 16 | > { 17 | private relationshipBuilder: RelationshipBuilder 18 | private statesManager: StatesManager 19 | 20 | constructor(private factory: Factory) { 21 | this.relationshipBuilder = new RelationshipBuilder(factory) 22 | this.statesManager = new StatesManager(factory) 23 | } 24 | 25 | /** 26 | * If the builder is at its initial state 27 | */ 28 | private isReset = true 29 | 30 | /** 31 | * The attributes that will be merged for the next created models. 32 | */ 33 | private mergeInput: Partial | Partial[] = [] 34 | 35 | /** 36 | * Ensure a knex connection is alive 37 | */ 38 | private ensureFactoryConnectionIsSet(knex: Knex | null): knex is Knex { 39 | if (knex) return true 40 | throw new Error('You must set a connection to the database before using the factory') 41 | } 42 | 43 | /** 44 | * Get the merge attributes for the given index. 45 | */ 46 | public getMergeAttributes(index: number) { 47 | if (Array.isArray(this.mergeInput)) { 48 | return this.mergeInput[index] || {} 49 | } 50 | 51 | return this.mergeInput 52 | } 53 | 54 | /** 55 | * Merge custom attributes on final rows 56 | */ 57 | private mergeAttributes(rows: Record[]) { 58 | if (Array.isArray(this.mergeInput)) { 59 | return rows.map((row, index) => defu(this.getMergeAttributes(index), row)) 60 | } 61 | 62 | return rows.map((row) => defu(this.mergeInput, row)) 63 | } 64 | 65 | /** 66 | * Unwrap factory fields that are functions. 67 | */ 68 | private async unwrapComputedFields(rows: Record[]) { 69 | const unwrappings = rows.map(async (row) => { 70 | const unwrappedRow: Record = {} 71 | 72 | for (const [key, value] of Object.entries(row)) { 73 | if (typeof value === 'function') { 74 | const fn = row[key] 75 | const result = await fn() 76 | 77 | unwrappedRow[key] = result?.id || result 78 | } else { 79 | unwrappedRow[key] = value 80 | } 81 | } 82 | 83 | return unwrappedRow 84 | }) 85 | 86 | return Promise.all(unwrappings) 87 | } 88 | 89 | /** 90 | * Reset the builder to its initial state. 91 | */ 92 | private reset() { 93 | this.isReset = true 94 | this.mergeInput = [] 95 | this.statesManager.reset() 96 | this.relationshipBuilder.reset() 97 | 98 | Object.values(this.factory.relations).forEach((relation) => { 99 | const factory = relation.factory() 100 | if (factory.isReset === false) factory.reset() 101 | }) 102 | } 103 | 104 | /** 105 | * Store a merge data that will be used when creating a new model. 106 | */ 107 | public merge(data: Partial | Partial[]) { 108 | this.mergeInput = data 109 | return this 110 | } 111 | 112 | /** 113 | * Apply a registered state 114 | */ 115 | public apply(state: States) { 116 | this.statesManager.register(state) 117 | return this 118 | } 119 | 120 | /** 121 | * Apply a relationship 122 | */ 123 | public with(name: Relationships, callback?: WithCallback): this 124 | public with(name: Relationships, count: number, callback?: WithCallback): this 125 | public with( 126 | name: Relationships, 127 | countOrCallback?: number | WithCallback, 128 | callback?: WithCallback 129 | ) { 130 | if (typeof countOrCallback === 'function') { 131 | this.relationshipBuilder.apply(name as string, 1, countOrCallback) 132 | } else { 133 | this.relationshipBuilder.apply(name as string, countOrCallback || 1, callback) 134 | } 135 | return this 136 | } 137 | 138 | /** 139 | * Create the models. Either by persisting them to the database or 140 | * by returning them as a plain object. 141 | */ 142 | private async instantiateModels(count: number, stubbed: boolean) { 143 | this.isReset = false 144 | let models: Record[] = [] 145 | 146 | /** 147 | * Generate fields for each row by calling the factory callback 148 | */ 149 | models = Array.from({ length: count }).map(() => 150 | this.factory.callback({ faker, isStubbed: false }) 151 | ) 152 | 153 | /** 154 | * Apply merge attributes 155 | */ 156 | models = this.mergeAttributes(models) 157 | 158 | /** 159 | * Apply the states 160 | */ 161 | models = this.statesManager.applyStates(models) 162 | 163 | /** 164 | * Unwrap computed fields by calling their callbacks 165 | */ 166 | models = await this.unwrapComputedFields(models) 167 | 168 | /** 169 | * We now create the belongsTo relationships 170 | */ 171 | await this.relationshipBuilder.createPre(models, stubbed) 172 | 173 | /** 174 | * Insert rows 175 | */ 176 | let result: Record[] = [] 177 | 178 | if (!stubbed) { 179 | result = await factorifyConfig 180 | .knex!.insert(convertCase(models, factorifyConfig.casing.insert)) 181 | .into(this.factory.tableName) 182 | .returning('*') 183 | } else { 184 | result = models 185 | } 186 | 187 | /** 188 | * Create post relationships 189 | */ 190 | await this.relationshipBuilder.createPost(result, stubbed) 191 | 192 | /** 193 | * Hydrate pre relationships into the result 194 | */ 195 | const finalModels = this.relationshipBuilder.postHydrate(result) 196 | 197 | this.reset() 198 | 199 | return finalModels.map((model) => convertCase(model, factorifyConfig.casing.return)) as Model[] 200 | } 201 | 202 | /** 203 | * Create a model without persisting it to the database. 204 | */ 205 | public async make(): Promise { 206 | const res = await this.makeMany(1) 207 | return res[0]! 208 | } 209 | 210 | /** 211 | * Create models without persisting them to the database. 212 | */ 213 | public async makeMany(count: number): Promise { 214 | return this.instantiateModels(count, true) 215 | } 216 | 217 | /** 218 | * Create a new model and persist it to the database. 219 | */ 220 | public async create(): Promise { 221 | this.ensureFactoryConnectionIsSet(factorifyConfig.knex) 222 | const res = await this.createMany(1) 223 | return res[0]! 224 | } 225 | 226 | /** 227 | * Create multiple models and persist them to the database. 228 | */ 229 | public async createMany(count: number): Promise { 230 | this.ensureFactoryConnectionIsSet(factorifyConfig.knex) 231 | return this.instantiateModels(count, false) 232 | } 233 | } 234 | --------------------------------------------------------------------------------