├── .eslintignore ├── .prettierignore ├── .prettierrc.json ├── src ├── drivers │ └── default │ │ ├── enums │ │ ├── filterType.ts │ │ ├── sortDirection.ts │ │ ├── authDriver.ts │ │ ├── httpMethod.ts │ │ └── filterOperator.ts │ │ ├── results │ │ ├── attachResult.ts │ │ ├── detachResult.ts │ │ ├── updatePivotResult.ts │ │ ├── toggleResult.ts │ │ └── syncResult.ts │ │ ├── scope.ts │ │ ├── sorter.ts │ │ ├── filter.ts │ │ ├── relations │ │ ├── morphMany.ts │ │ ├── hasManyThrough.ts │ │ ├── hasOne.ts │ │ ├── morphTo.ts │ │ ├── belongsTo.ts │ │ ├── morphOne.ts │ │ ├── hasOneThrough.ts │ │ ├── morphToMany.ts │ │ ├── hasMany.ts │ │ └── belongsToMany.ts │ │ └── builders │ │ ├── relationQueryBuilder.ts │ │ └── queryBuilder.ts ├── types │ ├── defaultPersistedAttributes.ts │ ├── AggregateItem.ts │ ├── defaultSoftDeletablePersistedAttributes.ts │ ├── defaultPersistedAttributesWithSoftDeletes.ts │ ├── extractModelKeyType.ts │ ├── extractModelRelationsType.ts │ ├── extractModelAttributesType.ts │ ├── extractModelAllAttributesType.ts │ ├── extractModelPersistedAttributesType.ts │ └── ModelRelations.ts ├── contracts │ └── modelConstructor.ts ├── builders │ └── urlBuilder.ts ├── httpClient.ts ├── model.ts └── orion.ts ├── jest.config.js ├── .gitignore ├── .editorconfig ├── .eslintrc ├── tests ├── integration │ ├── drivers │ │ └── default │ │ │ ├── serializer.ts │ │ │ ├── relations │ │ │ ├── hasMany.test.ts │ │ │ └── belongsToMany.test.ts │ │ │ ├── server.ts │ │ │ └── builders │ │ │ └── queryBuilder.test.ts │ ├── httpClient.test.ts │ ├── model.test.ts │ ├── orion.test.ts │ ├── builders │ │ └── urlBuilder.test.ts │ └── batch.test.ts ├── stubs │ └── models │ │ ├── tag.ts │ │ ├── user.ts │ │ └── post.ts └── unit │ ├── model.test.ts │ └── orion.test.ts ├── tsconfig.json ├── README.md ├── package.json └── .github └── workflows └── default.yml /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /src/drivers/default/enums/filterType.ts: -------------------------------------------------------------------------------- 1 | export enum FilterType { 2 | And = 'and', 3 | Or = 'or', 4 | } 5 | -------------------------------------------------------------------------------- /src/drivers/default/enums/sortDirection.ts: -------------------------------------------------------------------------------- 1 | export enum SortDirection { 2 | Asc = 'asc', 3 | Desc = 'desc', 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | moduleNameMapper: { 4 | "axios": "axios/dist/node/axios.cjs" 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /src/drivers/default/results/attachResult.ts: -------------------------------------------------------------------------------- 1 | export class AttachResult { 2 | constructor(public attached: Array) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/drivers/default/results/detachResult.ts: -------------------------------------------------------------------------------- 1 | export class DetachResult { 2 | constructor(public detached: Array) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/drivers/default/results/updatePivotResult.ts: -------------------------------------------------------------------------------- 1 | export class UpdatePivotResult { 2 | constructor(public updated: Array) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/drivers/default/enums/authDriver.ts: -------------------------------------------------------------------------------- 1 | export enum AuthDriver { 2 | Default = 'default', 3 | Passport = 'passport', 4 | Sanctum = 'sanctum', 5 | } 6 | -------------------------------------------------------------------------------- /src/drivers/default/enums/httpMethod.ts: -------------------------------------------------------------------------------- 1 | export enum HttpMethod { 2 | GET = 'get', 3 | POST = 'post', 4 | PATCH = 'patch', 5 | PUT = 'put', 6 | DELETE = 'delete', 7 | } 8 | -------------------------------------------------------------------------------- /src/types/defaultPersistedAttributes.ts: -------------------------------------------------------------------------------- 1 | export type DefaultPersistedAttributes = { 2 | id: number; 3 | updated_at: string | null; 4 | created_at: string | null; 5 | }; 6 | -------------------------------------------------------------------------------- /src/drivers/default/results/toggleResult.ts: -------------------------------------------------------------------------------- 1 | export class ToggleResult { 2 | constructor(public attached: Array, public detached: Array) {} 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | coverage 4 | .nyc_output 5 | .DS_Store 6 | *.log 7 | .vscode 8 | .idea 9 | dist 10 | lib 11 | compiled 12 | .awcache 13 | .rpt2_cache 14 | .yarn 15 | -------------------------------------------------------------------------------- /src/types/AggregateItem.ts: -------------------------------------------------------------------------------- 1 | import { ModelRelations } from './ModelRelations'; 2 | 3 | export type AggregateItem = { 4 | relation: ModelRelations; 5 | column: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/drivers/default/results/syncResult.ts: -------------------------------------------------------------------------------- 1 | export class SyncResult { 2 | constructor( 3 | public attached: Array, 4 | public updated: Array, 5 | public detached: Array 6 | ) {} 7 | } 8 | -------------------------------------------------------------------------------- /src/types/defaultSoftDeletablePersistedAttributes.ts: -------------------------------------------------------------------------------- 1 | import { DefaultPersistedAttributes } from './defaultPersistedAttributes'; 2 | 3 | export type DefaultSoftDeletablePersistedAttributes = DefaultPersistedAttributes & { 4 | deleted_at: string | null; 5 | }; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /src/types/defaultPersistedAttributesWithSoftDeletes.ts: -------------------------------------------------------------------------------- 1 | import { DefaultPersistedAttributes } from './defaultPersistedAttributes'; 2 | 3 | export type DefaultPersistedAttributesWithSoftDeletes = T & 4 | DefaultPersistedAttributes & { 5 | deleted_at: string | null; 6 | }; 7 | -------------------------------------------------------------------------------- /src/types/extractModelKeyType.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../model'; 2 | 3 | export type ExtractModelKeyType = T extends Model< 4 | infer Attributes, 5 | infer PersistedAttributes, 6 | infer Relations, 7 | infer Key, 8 | infer AllAttributes 9 | > 10 | ? Key 11 | : never; 12 | -------------------------------------------------------------------------------- /src/drivers/default/scope.ts: -------------------------------------------------------------------------------- 1 | export class Scope { 2 | constructor(protected name: string, protected parameters: Array = []) {} 3 | 4 | public getName(): string { 5 | return this.name; 6 | } 7 | 8 | public getParameters(): Array { 9 | return this.parameters; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/types/extractModelRelationsType.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../model'; 2 | 3 | export type ExtractModelRelationsType = T extends Model< 4 | infer Attributes, 5 | infer PersistedAttributes, 6 | infer Relations, 7 | infer Key, 8 | infer AllAttributes 9 | > 10 | ? Relations 11 | : never; 12 | -------------------------------------------------------------------------------- /src/types/extractModelAttributesType.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../model'; 2 | 3 | export type ExtractModelAttributesType = T extends Model< 4 | infer Attributes, 5 | infer PersistedAttributes, 6 | infer Relations, 7 | infer Key, 8 | infer AllAttributes 9 | > 10 | ? Attributes 11 | : never; 12 | -------------------------------------------------------------------------------- /src/drivers/default/enums/filterOperator.ts: -------------------------------------------------------------------------------- 1 | export enum FilterOperator { 2 | LessThan = '<', 3 | LessThanOrEqual = '<=', 4 | GreaterThan = '>', 5 | GreaterThanOrEqual = '>=', 6 | Equal = '=', 7 | NotEqual = '!=', 8 | Like = 'like', 9 | NotLike = 'not like', 10 | In = 'in', 11 | NotIn = 'not in', 12 | } 13 | -------------------------------------------------------------------------------- /src/types/extractModelAllAttributesType.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../model'; 2 | 3 | export type ExtractModelAllAttributesType = T extends Model< 4 | infer Attributes, 5 | infer PersistedAttributes, 6 | infer Relations, 7 | infer Key, 8 | infer AllAttributes 9 | > 10 | ? AllAttributes 11 | : never; 12 | -------------------------------------------------------------------------------- /src/types/extractModelPersistedAttributesType.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../model'; 2 | 3 | export type ExtractModelPersistedAttributesType = T extends Model< 4 | infer Attributes, 5 | infer PersistedAttributes, 6 | infer Relations, 7 | infer Key, 8 | infer AllAttributes 9 | > 10 | ? PersistedAttributes 11 | : never; 12 | -------------------------------------------------------------------------------- /src/drivers/default/sorter.ts: -------------------------------------------------------------------------------- 1 | import { SortDirection } from './enums/sortDirection'; 2 | 3 | export class Sorter { 4 | constructor(protected field: string, protected direction: SortDirection = SortDirection.Asc) {} 5 | 6 | public getField(): string { 7 | return this.field; 8 | } 9 | 10 | public getDirection(): SortDirection { 11 | return this.direction; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "prettier" 12 | ], 13 | "rules": { 14 | "@typescript-eslint/no-inferrable-types": "off" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/integration/drivers/default/serializer.ts: -------------------------------------------------------------------------------- 1 | import { RestSerializer } from 'miragejs'; 2 | import { snakeCase } from 'change-case'; 3 | 4 | export const LaravelSerializer = RestSerializer.extend({ 5 | keyForCollection() { 6 | return 'data'; 7 | }, 8 | keyForModel() { 9 | return 'data'; 10 | }, 11 | keyForAttribute(attr) { 12 | return snakeCase(attr); 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/types/ModelRelations.ts: -------------------------------------------------------------------------------- 1 | import { ExtractModelRelationsType } from './extractModelRelationsType'; 2 | 3 | export type DirectModelRelations = string & (keyof Relations) 4 | export type ChildModelRelations = `${DirectModelRelations}.${DirectModelRelations>}` 5 | 6 | 7 | export type ModelRelations = 8 | DirectModelRelations 9 | | ChildModelRelations 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "ES6", 5 | "module": "ESNext", 6 | "strict": true, 7 | "sourceMap": true, 8 | "declaration": true, 9 | "allowSyntheticDefaultImports": true, 10 | "suppressImplicitAnyIndexErrors": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "esModuleInterop": true, 14 | "outDir": "lib", 15 | "typeRoots": [ 16 | "node_modules/@types" 17 | ] 18 | }, 19 | "include": [ 20 | "src" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tests/stubs/models/tag.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../src/model'; 2 | import Post from './post'; 3 | import { BelongsToMany } from '../../../src/drivers/default/relations/belongsToMany'; 4 | import { DefaultPersistedAttributes } from '../../../src/types/defaultPersistedAttributes'; 5 | 6 | export default class Tag extends Model<{ 7 | content: string; 8 | }, DefaultPersistedAttributes, 9 | { 10 | posts: Post[]; 11 | }> { 12 | $resource(): string { 13 | return 'tags'; 14 | } 15 | 16 | public posts(): BelongsToMany { 17 | return new BelongsToMany(Post, this); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/drivers/default/filter.ts: -------------------------------------------------------------------------------- 1 | import { FilterOperator } from './enums/filterOperator'; 2 | import { FilterType } from './enums/filterType'; 3 | 4 | export class Filter { 5 | constructor( 6 | protected field: string, 7 | protected operator: FilterOperator, 8 | protected value: any, 9 | protected type?: FilterType 10 | ) {} 11 | 12 | public getField(): string { 13 | return this.field; 14 | } 15 | 16 | public getOperator(): FilterOperator { 17 | return this.operator; 18 | } 19 | 20 | public getValue(): any { 21 | return this.value; 22 | } 23 | 24 | public getType(): FilterType | undefined { 25 | return this.type; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/stubs/models/user.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../src/model'; 2 | import Post from './post'; 3 | import { HasMany } from '../../../src/drivers/default/relations/hasMany'; 4 | import { DefaultPersistedAttributes } from '../../../src/types/defaultPersistedAttributes'; 5 | 6 | export type UserAttributes = { 7 | name: string; 8 | } 9 | 10 | export type UserRelations = { 11 | posts: Post[]; 12 | } 13 | export default class User extends Model { 14 | $resource(): string { 15 | return 'users'; 16 | } 17 | 18 | public posts(): HasMany { 19 | return new HasMany(Post, this); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/unit/model.test.ts: -------------------------------------------------------------------------------- 1 | import { Orion } from '../../src/orion'; 2 | import Post from '../stubs/models/post'; 3 | 4 | Orion.setBaseUrl('https://example.com/api'); 5 | 6 | describe('Model tests', () => { 7 | test('setting and getting key name', () => { 8 | const model = new Post(); 9 | model.$setKeyName('custom_key'); 10 | 11 | expect(model.$getKeyName()).toBe('custom_key'); 12 | }); 13 | 14 | test('setting and getting key', () => { 15 | const model = new Post(); 16 | model.$setKey(123); 17 | 18 | expect(model.$getKey()).toBe(123); 19 | }); 20 | 21 | test('getting resource name', () => { 22 | expect(Post.prototype.$resource()).toBe('posts'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/drivers/default/relations/morphMany.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../model'; 2 | import { HasMany } from './hasMany'; 3 | import { ExtractModelAttributesType } from '../../../types/extractModelAttributesType'; 4 | import { ExtractModelPersistedAttributesType } from '../../../types/extractModelPersistedAttributesType'; 5 | import { ExtractModelRelationsType } from '../../../types/extractModelRelationsType'; 6 | 7 | export class MorphMany< 8 | Relation extends Model, 9 | Attributes = ExtractModelAttributesType, 10 | PersistedAttributes = ExtractModelPersistedAttributesType, 11 | Relations = ExtractModelRelationsType 12 | > extends HasMany {} 13 | -------------------------------------------------------------------------------- /src/drivers/default/relations/hasManyThrough.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../model'; 2 | import { HasMany } from './hasMany'; 3 | import { ExtractModelAttributesType } from '../../../types/extractModelAttributesType'; 4 | import { ExtractModelPersistedAttributesType } from '../../../types/extractModelPersistedAttributesType'; 5 | import { ExtractModelRelationsType } from '../../../types/extractModelRelationsType'; 6 | 7 | export class HasManyThrough< 8 | Relation extends Model, 9 | Attributes = ExtractModelAttributesType, 10 | PersistedAttributes = ExtractModelPersistedAttributesType, 11 | Relations = ExtractModelRelationsType 12 | > extends HasMany {} 13 | -------------------------------------------------------------------------------- /src/drivers/default/relations/hasOne.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../model'; 2 | import { RelationQueryBuilder } from '../builders/relationQueryBuilder'; 3 | import { ExtractModelAttributesType } from '../../../types/extractModelAttributesType'; 4 | import { ExtractModelPersistedAttributesType } from '../../../types/extractModelPersistedAttributesType'; 5 | import { ExtractModelRelationsType } from '../../../types/extractModelRelationsType'; 6 | 7 | export class HasOne< 8 | Relation extends Model, 9 | Attributes = ExtractModelAttributesType, 10 | PersistedAttributes = ExtractModelPersistedAttributesType, 11 | Relations = ExtractModelRelationsType 12 | > extends RelationQueryBuilder {} 13 | -------------------------------------------------------------------------------- /src/drivers/default/relations/morphTo.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../model'; 2 | import { RelationQueryBuilder } from '../builders/relationQueryBuilder'; 3 | import { ExtractModelAttributesType } from '../../../types/extractModelAttributesType'; 4 | import { ExtractModelPersistedAttributesType } from '../../../types/extractModelPersistedAttributesType'; 5 | import { ExtractModelRelationsType } from '../../../types/extractModelRelationsType'; 6 | 7 | export class MorphTo< 8 | Relation extends Model, 9 | Attributes = ExtractModelAttributesType, 10 | PersistedAttributes = ExtractModelPersistedAttributesType, 11 | Relations = ExtractModelRelationsType 12 | > extends RelationQueryBuilder {} 13 | -------------------------------------------------------------------------------- /src/drivers/default/relations/belongsTo.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../model'; 2 | import { RelationQueryBuilder } from '../builders/relationQueryBuilder'; 3 | import { ExtractModelAttributesType } from '../../../types/extractModelAttributesType'; 4 | import { ExtractModelPersistedAttributesType } from '../../../types/extractModelPersistedAttributesType'; 5 | import { ExtractModelRelationsType } from '../../../types/extractModelRelationsType'; 6 | 7 | export class BelongsTo< 8 | Relation extends Model, 9 | Attributes = ExtractModelAttributesType, 10 | PersistedAttributes = ExtractModelPersistedAttributesType, 11 | Relations = ExtractModelRelationsType 12 | > extends RelationQueryBuilder {} 13 | -------------------------------------------------------------------------------- /src/drivers/default/relations/morphOne.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../model'; 2 | import { RelationQueryBuilder } from '../builders/relationQueryBuilder'; 3 | import { ExtractModelAttributesType } from '../../../types/extractModelAttributesType'; 4 | import { ExtractModelPersistedAttributesType } from '../../../types/extractModelPersistedAttributesType'; 5 | import { ExtractModelRelationsType } from '../../../types/extractModelRelationsType'; 6 | 7 | export class MorphOne< 8 | Relation extends Model, 9 | Attributes = ExtractModelAttributesType, 10 | PersistedAttributes = ExtractModelPersistedAttributesType, 11 | Relations = ExtractModelRelationsType 12 | > extends RelationQueryBuilder {} 13 | -------------------------------------------------------------------------------- /tests/integration/httpClient.test.ts: -------------------------------------------------------------------------------- 1 | import { Orion } from '../../src/orion'; 2 | import makeServer from './drivers/default/server'; 3 | import { HttpMethod } from '../../src/drivers/default/enums/httpMethod'; 4 | 5 | let server: any; 6 | 7 | beforeEach(() => { 8 | server = makeServer(); 9 | }); 10 | 11 | afterEach(() => { 12 | server.shutdown(); 13 | }); 14 | 15 | describe('HttpClient tests', () => { 16 | test('using bearer token', async () => { 17 | server.schema.posts.create({ title: 'Test Post' }); 18 | 19 | Orion.setToken('test'); 20 | await Orion.makeHttpClient().request('/posts', HttpMethod.GET); 21 | 22 | const requests = server.pretender.handledRequests; 23 | expect(requests[0].requestHeaders['Authorization']).toStrictEqual('Bearer test'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/drivers/default/relations/hasOneThrough.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../model'; 2 | import { RelationQueryBuilder } from '../builders/relationQueryBuilder'; 3 | import { ExtractModelAttributesType } from '../../../types/extractModelAttributesType'; 4 | import { ExtractModelPersistedAttributesType } from '../../../types/extractModelPersistedAttributesType'; 5 | import { ExtractModelRelationsType } from '../../../types/extractModelRelationsType'; 6 | 7 | export class HasOneThrough< 8 | Relation extends Model, 9 | Attributes = ExtractModelAttributesType, 10 | PersistedAttributes = ExtractModelPersistedAttributesType, 11 | Relations = ExtractModelRelationsType 12 | > extends RelationQueryBuilder {} 13 | -------------------------------------------------------------------------------- /src/drivers/default/relations/morphToMany.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../model'; 2 | import { BelongsToMany } from './belongsToMany'; 3 | import { ExtractModelAttributesType } from '../../../types/extractModelAttributesType'; 4 | import { ExtractModelPersistedAttributesType } from '../../../types/extractModelPersistedAttributesType'; 5 | import { ExtractModelRelationsType } from '../../../types/extractModelRelationsType'; 6 | 7 | export class MorphToMany< 8 | Relation extends Model, 9 | Pivot = Record, 10 | Attributes = ExtractModelAttributesType, 11 | PersistedAttributes = ExtractModelPersistedAttributesType, 12 | Relations = ExtractModelRelationsType 13 | > extends BelongsToMany {} 14 | -------------------------------------------------------------------------------- /src/contracts/modelConstructor.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../model'; 2 | import { ExtractModelAttributesType } from '../types/extractModelAttributesType'; 3 | import { ExtractModelPersistedAttributesType } from '../types/extractModelPersistedAttributesType'; 4 | import { ExtractModelRelationsType } from '../types/extractModelRelationsType'; 5 | import { ExtractModelKeyType } from '../types/extractModelKeyType'; 6 | 7 | export interface ModelConstructor< 8 | M extends Model, 9 | Attributes = ExtractModelAttributesType, 10 | PersistedAttributes = ExtractModelPersistedAttributesType, 11 | Relations = ExtractModelRelationsType, 12 | Key = ExtractModelKeyType, 13 | AllAttributes = Attributes & PersistedAttributes 14 | > { 15 | new (attributes?: AllAttributes, relations?: Relations): M; 16 | } 17 | -------------------------------------------------------------------------------- /tests/stubs/models/post.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../src/model'; 2 | import User from './user'; 3 | import { BelongsTo } from '../../../src/drivers/default/relations/belongsTo'; 4 | import { DefaultPersistedAttributes } from '../../../src/types/defaultPersistedAttributes'; 5 | import Tag from './tag'; 6 | import { HasMany } from '../../../src/drivers/default/relations/hasMany'; 7 | 8 | export default class Post extends Model< 9 | { 10 | title: string; 11 | }, 12 | DefaultPersistedAttributes, 13 | { 14 | user: User; 15 | tags?: Tag[]; 16 | } 17 | > { 18 | $resource(): string { 19 | return 'posts'; 20 | } 21 | 22 | public user(): BelongsTo { 23 | return new BelongsTo(User, this); 24 | } 25 | 26 | public tags(): HasMany { 27 | return new HasMany(Tag, this); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | Latest Version on NPM 7 | Build Status 8 |

9 | 10 | ## Introduction 11 | 12 | Laravel Orion TypeScript SDK allows you to build expressive frontend applications powered by REST API. 13 | 14 | ## Official Documentation 15 | 16 | Documentation for Laravel Orion and its TypeScript SDK can be found on the [website](https://tailflow.github.io/laravel-orion-docs/). 17 | -------------------------------------------------------------------------------- /tests/integration/model.test.ts: -------------------------------------------------------------------------------- 1 | import makeServer from './drivers/default/server'; 2 | import Post from '../stubs/models/post'; 3 | 4 | let server: any; 5 | 6 | beforeEach(() => { 7 | server = makeServer(); 8 | }); 9 | 10 | afterEach(() => { 11 | server.shutdown(); 12 | }); 13 | 14 | describe('Model tests', () => { 15 | test('saving a model', async () => { 16 | server.schema.posts.create({ title: 'Test Post' }); 17 | 18 | const post = await Post.$query().find(1); 19 | 20 | post.$attributes.title = 'Updated Post'; 21 | await post.$save(); 22 | 23 | expect(server.schema.posts.find('1').attrs.title).toBe('Updated Post'); 24 | }); 25 | 26 | test('trashing a model', async () => { 27 | server.schema.posts.create({ title: 'Test Post' }); 28 | 29 | const post = await Post.$query().find(1); 30 | 31 | await post.$destroy(); 32 | 33 | expect(server.schema.posts.find('1').attrs.deleted_at).toBeDefined(); 34 | }); 35 | 36 | test('force deleting a model', async () => { 37 | server.schema.posts.create({ title: 'Test Post' }); 38 | 39 | const post = await Post.$query().find(1); 40 | 41 | await post.$destroy(true); 42 | 43 | expect(server.schema.posts.find('1')).toBeNull(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /tests/integration/orion.test.ts: -------------------------------------------------------------------------------- 1 | import { Orion } from '../../src/orion'; 2 | import { AuthDriver } from '../../src/drivers/default/enums/authDriver'; 3 | import makeServer from './drivers/default/server'; 4 | 5 | let server: any; 6 | 7 | beforeEach(() => { 8 | server = makeServer(); 9 | }); 10 | 11 | afterEach(() => { 12 | server.shutdown(); 13 | }); 14 | 15 | describe('Orion tests', () => { 16 | test('retrieving csrf cookie', async () => { 17 | Orion.setAuthDriver(AuthDriver.Sanctum); 18 | 19 | await Orion.csrf(); 20 | 21 | const requests = server.pretender.handledRequests; 22 | expect(requests[0].url).toBe('https://api-mock.test/sanctum/csrf-cookie'); 23 | }); 24 | 25 | test('attempting to fetch csrf cookie with invalid driver', async () => { 26 | Orion.setAuthDriver(AuthDriver.Passport); 27 | 28 | try { 29 | await Orion.csrf(); 30 | expect(false).toBeTruthy(); 31 | } catch (error) { 32 | expect((error as Error).message).toBe( 33 | `Current auth driver is set to "${AuthDriver.Passport}". Fetching CSRF cookie can only be used with "sanctum" driver.` 34 | ); 35 | } 36 | 37 | const requests = server.pretender.handledRequests; 38 | expect(requests).toHaveLength(0); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tests/integration/builders/urlBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import { UrlBuilder } from '../../../src/builders/urlBuilder'; 2 | import Post from '../../stubs/models/post'; 3 | import { Orion } from '../../../src/orion'; 4 | import User from '../../stubs/models/user'; 5 | 6 | describe('UriBuilder tests', () => { 7 | test('building resource base url', () => { 8 | Orion.init('https://example.com'); 9 | 10 | const resourceBaseUrl = UrlBuilder.getResourceBaseUrl(Post); 11 | 12 | expect(resourceBaseUrl).toBe('https://example.com/api/posts'); 13 | }); 14 | 15 | test('building resource url', () => { 16 | Orion.init('https://example.com'); 17 | 18 | const resourceUrl = UrlBuilder.getResourceUrl( 19 | new Post({ 20 | id: 1, 21 | title: 'New Post', 22 | created_at: null, 23 | updated_at: null, 24 | }) 25 | ); 26 | 27 | expect(resourceUrl).toBe('https://example.com/api/posts/1'); 28 | }); 29 | 30 | test('building relation resource url', () => { 31 | Orion.init('https://example.com'); 32 | 33 | const resourceUrl = UrlBuilder.getRelationResourceUrl( 34 | new User({ 35 | id: 1, 36 | name: 'New User', 37 | created_at: null, 38 | updated_at: null, 39 | }), 40 | Post 41 | ); 42 | 43 | expect(resourceUrl).toBe('https://example.com/api/users/1/posts'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tailflow/laravel-orion", 3 | "version": "4.1.0", 4 | "description": "Typescript SDK for Laravel Orion", 5 | "keywords": [ 6 | "laravel orion", 7 | "rest", 8 | "api", 9 | "laravel", 10 | "sdk" 11 | ], 12 | "files": [ 13 | "lib" 14 | ], 15 | "author": "Aleksei Zarubin alex@zarubin.co", 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/tailflow/laravel-orion-ts.git" 19 | }, 20 | "license": "MIT", 21 | "scripts": { 22 | "prebuild": "rimraf lib", 23 | "build": "tsc", 24 | "dev": "tsc -w", 25 | "test": "jest", 26 | "lint": "eslint . --ext .ts" 27 | }, 28 | "devDependencies": { 29 | "@types/jest": "^26.0.20", 30 | "@typescript-eslint/eslint-plugin": "^4.19.0", 31 | "@typescript-eslint/parser": "^4.19.0", 32 | "change-case": "^4.1.2", 33 | "eslint": "^7.22.0", 34 | "eslint-config-prettier": "^8.1.0", 35 | "jest": "^26.6.3", 36 | "miragejs": "^0.1.41", 37 | "prettier": "2.2.1", 38 | "rimraf": "^2.6.2", 39 | "ts-jest": "^26.5", 40 | "typescript": "^4.2" 41 | }, 42 | "dependencies": { 43 | "axios": "^1.6.0" 44 | }, 45 | "bugs": { 46 | "url": "https://github.com/tailflow/laravel-orion-ts/issues" 47 | }, 48 | "homepage": "https://github.com/tailflow/laravel-orion-ts#readme", 49 | "directories": { 50 | "test": "tests" 51 | }, 52 | "publishConfig": { 53 | "access": "public" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/drivers/default/builders/relationQueryBuilder.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../model'; 2 | import { QueryBuilder } from './queryBuilder'; 3 | import { ModelConstructor } from '../../../contracts/modelConstructor'; 4 | import { UrlBuilder } from '../../../builders/urlBuilder'; 5 | import { ExtractModelAttributesType } from '../../../types/extractModelAttributesType'; 6 | import { ExtractModelPersistedAttributesType } from '../../../types/extractModelPersistedAttributesType'; 7 | import { ExtractModelRelationsType } from '../../../types/extractModelRelationsType'; 8 | import { Orion } from '../../../orion'; 9 | import { ExtractModelKeyType } from '../../../types/extractModelKeyType'; 10 | 11 | export class RelationQueryBuilder< 12 | Relation extends Model, 13 | Attributes = ExtractModelAttributesType, 14 | PersistedAttributes = ExtractModelPersistedAttributesType, 15 | Relations = ExtractModelRelationsType, 16 | Key = ExtractModelKeyType 17 | > extends QueryBuilder { 18 | constructor( 19 | relationConstructor: ModelConstructor< 20 | Relation, 21 | Attributes, 22 | PersistedAttributes, 23 | Relations, 24 | Key 25 | >, 26 | parent: Model 27 | ) { 28 | super(relationConstructor); 29 | 30 | this.modelConstructor = relationConstructor; 31 | this.baseUrl = UrlBuilder.getRelationResourceUrl(parent, relationConstructor); 32 | this.httpClient = Orion.makeHttpClient(this.baseUrl); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/drivers/default/relations/hasMany.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../../model'; 2 | import { RelationQueryBuilder } from '../builders/relationQueryBuilder'; 3 | import { HttpMethod } from '../enums/httpMethod'; 4 | import { ExtractModelAttributesType } from '../../../types/extractModelAttributesType'; 5 | import { ExtractModelPersistedAttributesType } from '../../../types/extractModelPersistedAttributesType'; 6 | import { ExtractModelRelationsType } from '../../../types/extractModelRelationsType'; 7 | 8 | export class HasMany< 9 | Relation extends Model, 10 | Attributes = ExtractModelAttributesType, 11 | PersistedAttributes = ExtractModelPersistedAttributesType, 12 | Relations = ExtractModelRelationsType 13 | > extends RelationQueryBuilder { 14 | public async associate(key: string | number): Promise { 15 | const response = await this.httpClient.request<{data: Attributes & PersistedAttributes & Relations}>( 16 | `/associate`, 17 | HttpMethod.POST, 18 | this.prepareQueryParams(), 19 | { 20 | related_key: key, 21 | } 22 | ); 23 | 24 | return this.hydrate(response.data.data, response); 25 | } 26 | 27 | public async dissociate(key: string | number): Promise { 28 | const response = await this.httpClient.request<{data: Attributes & PersistedAttributes & Relations}>( 29 | `/${key}/dissociate`, 30 | HttpMethod.DELETE, 31 | this.prepareQueryParams() 32 | ); 33 | 34 | return this.hydrate(response.data.data, response); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/builders/urlBuilder.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../model'; 2 | import { Orion } from '../orion'; 3 | import { ModelConstructor } from '../contracts/modelConstructor'; 4 | import { ExtractModelAttributesType } from '../types/extractModelAttributesType'; 5 | import { ExtractModelPersistedAttributesType } from '../types/extractModelPersistedAttributesType'; 6 | import { ExtractModelRelationsType } from '../types/extractModelRelationsType'; 7 | import { ExtractModelKeyType } from '../types/extractModelKeyType'; 8 | 9 | export class UrlBuilder { 10 | public static getResourceBaseUrl< 11 | M extends Model, 12 | Attributes = ExtractModelAttributesType, 13 | PersistedAttributes = ExtractModelPersistedAttributesType, 14 | Relations = ExtractModelRelationsType, 15 | Key = ExtractModelKeyType 16 | >(model: ModelConstructor): string { 17 | return Orion.getApiUrl() + '/' + (model.prototype as M).$resource(); 18 | } 19 | 20 | public static getResourceUrl(model: Model): string { 21 | return ( 22 | UrlBuilder.getResourceBaseUrl(model.constructor as ModelConstructor) + 23 | `/${model.$getKey()}` 24 | ); 25 | } 26 | 27 | public static getRelationResourceUrl< 28 | R extends Model, 29 | Attributes = ExtractModelAttributesType, 30 | PersistedAttributes = ExtractModelPersistedAttributesType, 31 | Relations = ExtractModelRelationsType, 32 | Key = ExtractModelKeyType 33 | >( 34 | parent: Model, 35 | relationConstructor: ModelConstructor 36 | ): string { 37 | return ( 38 | UrlBuilder.getResourceUrl(parent) + '/' + (relationConstructor.prototype as R).$resource() 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/httpClient.ts: -------------------------------------------------------------------------------- 1 | import {HttpMethod} from './drivers/default/enums/httpMethod'; 2 | import {Orion} from './orion'; 3 | import {AxiosInstance, AxiosRequestConfig, AxiosResponse} from 'axios'; 4 | 5 | export class HttpClient { 6 | constructor(protected baseUrl: string, protected client: AxiosInstance) { 7 | this.baseUrl = baseUrl; 8 | this.client = client; 9 | } 10 | 11 | public async request>( 12 | url: string, 13 | method: HttpMethod, 14 | params?: Record | null, 15 | data?: Record 16 | ): Promise> { 17 | const config: AxiosRequestConfig = Object.assign(Orion.getHttpClientConfig(), { 18 | baseURL: this.baseUrl, 19 | url, 20 | method, 21 | params, 22 | }); 23 | 24 | if (method !== HttpMethod.GET) { 25 | config.data = data; 26 | } 27 | 28 | return this.client.request(config); 29 | } 30 | 31 | public async get>(url: string, params?: Record): Promise> { 32 | return this.request( 33 | url, HttpMethod.GET, params 34 | ) 35 | } 36 | 37 | public async post>(url: string, data: Record, params?: Record,): Promise> { 38 | return this.request( 39 | url, HttpMethod.POST, params, data 40 | ) 41 | } 42 | 43 | public async patch>(url: string, data: Record, params?: Record, ): Promise> { 44 | return this.request( 45 | url, HttpMethod.PATCH, params, data 46 | ) 47 | } 48 | 49 | public async delete>(url: string, params?: Record): Promise> { 50 | return this.request( 51 | url, HttpMethod.DELETE, params 52 | ) 53 | } 54 | 55 | public getAxios(): AxiosInstance { 56 | return this.client; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/unit/orion.test.ts: -------------------------------------------------------------------------------- 1 | import { Orion } from '../../src/orion'; 2 | import { AuthDriver } from '../../src/drivers/default/enums/authDriver'; 3 | import axios from 'axios'; 4 | 5 | describe('Orion tests', () => { 6 | test('initialization', () => { 7 | Orion.init('https://example.com', 'custom-prefix', AuthDriver.Passport, 'test-token'); 8 | 9 | expect(Orion.getBaseUrl()).toBe('https://example.com/'); 10 | expect(Orion.getPrefix()).toBe('custom-prefix'); 11 | expect(Orion.getAuthDriver()).toBe(AuthDriver.Passport); 12 | expect(Orion.getToken()).toBe('test-token'); 13 | }); 14 | 15 | test('getting and setting host', () => { 16 | Orion.setBaseUrl('https://example.com/'); 17 | 18 | expect(Orion.getBaseUrl()).toBe('https://example.com/'); 19 | }); 20 | 21 | test('getting and setting prefix', () => { 22 | Orion.setPrefix('api'); 23 | 24 | expect(Orion.getPrefix()).toBe('api'); 25 | }); 26 | 27 | test('getting api url', () => { 28 | Orion.setBaseUrl('https://example.com/'); 29 | Orion.setPrefix('api'); 30 | 31 | expect(Orion.getApiUrl()).toBe('https://example.com/api'); 32 | }); 33 | 34 | test('getting and setting token', () => { 35 | Orion.setToken('test'); 36 | 37 | expect(Orion.getToken()).toBe('test'); 38 | }); 39 | 40 | test('unsetting token', () => { 41 | Orion.setToken('test'); 42 | Orion.withoutToken(); 43 | 44 | expect(Orion.getToken()).toBeNull(); 45 | }); 46 | 47 | test('appending slash to the end when getting api url', () => { 48 | Orion.setBaseUrl('https://example.com/api'); 49 | 50 | expect(Orion.getBaseUrl()).toBe('https://example.com/api/'); 51 | }); 52 | 53 | test('making http client using user-provided callback', () => { 54 | Orion.init('https://example.com', 'custom-prefix', AuthDriver.Passport, 'test-token'); 55 | Orion.makeHttpClientUsing(() => { 56 | const client = axios.create(); 57 | 58 | client.defaults.baseURL = 'https://custom.com'; 59 | 60 | return client; 61 | }); 62 | 63 | expect(Orion.makeHttpClient().getAxios().defaults.baseURL).toBe('https://custom.com'); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /tests/integration/drivers/default/relations/hasMany.test.ts: -------------------------------------------------------------------------------- 1 | import { HasMany } from '../../../../../src/drivers/default/relations/hasMany'; 2 | import Post from '../../../../stubs/models/post'; 3 | import makeServer from '../server'; 4 | import User from '../../../../stubs/models/user'; 5 | 6 | let server: any; 7 | 8 | beforeEach(() => { 9 | server = makeServer(); 10 | }); 11 | 12 | afterEach(() => { 13 | server.shutdown(); 14 | }); 15 | 16 | describe('HasMany tests', () => { 17 | type PostAttributes = { 18 | title: string; 19 | }; 20 | 21 | test('associating a resource', async () => { 22 | const userEntity = server.schema.users.create({ name: 'Test User' }); 23 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 24 | 25 | const user = new User(userEntity.attrs); 26 | 27 | const hasManyRelation = new HasMany(Post, user); 28 | const associatedPost = await hasManyRelation.associate(postEntity.attrs.id); 29 | 30 | expect(associatedPost).toBeInstanceOf(Post); 31 | expect(associatedPost.$attributes).toStrictEqual({ id: '1', title: 'Test Post', user_id: '1' }); 32 | expect(server.schema.posts.find('1').attrs.user_id).toBe('1'); 33 | }); 34 | 35 | test('associating a soft deleted resource', async () => { 36 | const userEntity = server.schema.users.create({ name: 'Test User' }); 37 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 38 | 39 | const user = new User(userEntity.attrs); 40 | 41 | const hasManyRelation = new HasMany(Post, user); 42 | await hasManyRelation.withTrashed().associate(postEntity.attrs.id); 43 | 44 | const requests = server.pretender.handledRequests; 45 | expect(requests[0].queryParams).toStrictEqual({ with_trashed: 'true' }); 46 | }); 47 | 48 | test('dissociating a resource', async () => { 49 | const userEntity = server.schema.users.create({ name: 'Test User' }); 50 | const postEntity = server.schema.posts.create({ 51 | title: 'Test Post', 52 | user_id: userEntity.attrs.id, 53 | }); 54 | 55 | const user = new User(userEntity.attrs); 56 | 57 | const hasManyRelation = new HasMany(Post, user); 58 | const associatedPost = await hasManyRelation.dissociate(postEntity.attrs.id); 59 | 60 | expect(associatedPost).toBeInstanceOf(Post); 61 | expect(associatedPost.$attributes).toStrictEqual({ 62 | id: '1', 63 | title: 'Test Post', 64 | user_id: null, 65 | }); 66 | expect(server.schema.posts.find('1').attrs.user_id).toBeNull(); 67 | }); 68 | 69 | test('dissociating a soft deleted resource', async () => { 70 | const userEntity = server.schema.users.create({ name: 'Test User' }); 71 | const postEntity = server.schema.posts.create({ 72 | title: 'Test Post', 73 | user_id: userEntity.attrs.id, 74 | }); 75 | 76 | const user = new User(userEntity.attrs); 77 | 78 | const hasManyRelation = new HasMany(Post, user); 79 | await hasManyRelation.withTrashed().dissociate(postEntity.attrs.id); 80 | 81 | const requests = server.pretender.handledRequests; 82 | expect(requests[0].queryParams).toStrictEqual({ with_trashed: 'true' }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/model.ts: -------------------------------------------------------------------------------- 1 | import { QueryBuilder } from './drivers/default/builders/queryBuilder'; 2 | import { ModelConstructor } from './contracts/modelConstructor'; 3 | import { AxiosResponse } from 'axios'; 4 | import { DefaultPersistedAttributes } from './types/defaultPersistedAttributes'; 5 | 6 | export abstract class Model< 7 | Attributes = Record, 8 | PersistedAttributes = Record | DefaultPersistedAttributes, 9 | Relations = Record, 10 | Key extends number | string = number | string, 11 | AllAttributes = Attributes & PersistedAttributes 12 | > { 13 | public $attributes!: AllAttributes; 14 | public $relations!: Relations; 15 | 16 | public $response?: AxiosResponse; 17 | protected $keyName: string = 'id'; 18 | 19 | constructor(attributes?: AllAttributes, relations?: Relations) { 20 | this.$init(); 21 | 22 | if (attributes) { 23 | this.$setAttributes(attributes); 24 | } 25 | 26 | if (relations) { 27 | this.$setRelations(this.$relations); 28 | } 29 | } 30 | 31 | public abstract $resource(): string; 32 | 33 | public static $query(this: ModelConstructor): QueryBuilder { 34 | return new QueryBuilder(this); 35 | } 36 | 37 | public $query(): QueryBuilder { 38 | return new QueryBuilder(this.constructor as ModelConstructor); 39 | } 40 | 41 | public async $save(attributes?: Attributes): Promise { 42 | if (attributes) { 43 | this.$setAttributes((attributes as unknown) as AllAttributes); 44 | } 45 | 46 | await this.$query().update( 47 | this.$getKey(), 48 | (attributes || this.$attributes) as Record 49 | ); 50 | 51 | return this; 52 | } 53 | 54 | public async $destroy(force: boolean = false): Promise { 55 | await this.$query().destroy(this.$getKey(), force); 56 | 57 | return this; 58 | } 59 | 60 | public $getKeyName(): string { 61 | return this.$keyName; 62 | } 63 | 64 | public $setKeyName(keyName: string): this { 65 | this.$keyName = keyName; 66 | 67 | return this; 68 | } 69 | 70 | public $getKey(): Key { 71 | return this.$attributes[this.$getKeyName()]; 72 | } 73 | 74 | public $setKey(key: Key): this { 75 | this.$attributes[this.$getKeyName()] = key; 76 | 77 | return this; 78 | } 79 | 80 | public $setAttributes(attributes: AllAttributes): this { 81 | for (const attribute in attributes) { 82 | this.$attributes[attribute] = attributes[attribute]; 83 | } 84 | 85 | return this; 86 | } 87 | 88 | public $setRelations(relations: Relations): this { 89 | for (const relation in relations) { 90 | this.$relations[relation] = relations[relation]; 91 | } 92 | 93 | return this; 94 | } 95 | 96 | public $is(model: Model): boolean { 97 | return this.$getKey() === model.$getKey(); 98 | } 99 | 100 | protected $init(): void { 101 | if (!this.$attributes) { 102 | this.$attributes = {} as AllAttributes; 103 | } 104 | 105 | if (!this.$relations) { 106 | this.$relations = {} as Relations; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | name: default 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | release: 7 | types: [ published ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '20' 20 | 21 | - name: Cache NPM dependencies 22 | id: npm-cache 23 | uses: actions/cache@v3 24 | with: 25 | path: ~/.npm 26 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: | 28 | ${{ runner.os }}-node- 29 | 30 | - name: Install NPM dependencies 31 | if: steps.node-cache.outputs.cache-hit != 'true' 32 | run: npm ci 33 | 34 | - name: Test 35 | run: npm run test 36 | lint: 37 | needs: test 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v4 42 | 43 | - name: Setup Node.js 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version: '20' 47 | 48 | - name: Cache NPM dependencies 49 | id: npm-cache 50 | uses: actions/cache@v3 51 | with: 52 | path: ~/.npm 53 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 54 | restore-keys: | 55 | ${{ runner.os }}-node- 56 | 57 | - name: Install NPM dependencies 58 | if: steps.node-cache.outputs.cache-hit != 'true' 59 | run: npm ci 60 | 61 | - name: Lint 62 | run: npm run lint 63 | build: 64 | needs: lint 65 | runs-on: ubuntu-latest 66 | steps: 67 | - name: Checkout 68 | uses: actions/checkout@v4 69 | 70 | - name: Setup Node.js 71 | uses: actions/setup-node@v4 72 | with: 73 | node-version: '20' 74 | 75 | - name: Cache NPM dependencies 76 | id: npm-cache 77 | uses: actions/cache@v3 78 | with: 79 | path: ~/.npm 80 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 81 | restore-keys: | 82 | ${{ runner.os }}-node- 83 | 84 | - name: Install NPM dependencies 85 | if: steps.node-cache.outputs.cache-hit != 'true' 86 | run: npm ci 87 | 88 | - name: Build 89 | run: npm run build 90 | 91 | - name: Upload artifacts 92 | uses: actions/upload-artifact@v3 93 | with: 94 | name: package 95 | path: | 96 | ./ 97 | !node_modules 98 | publish: 99 | if: github.event_name == 'release' 100 | needs: build 101 | runs-on: ubuntu-latest 102 | steps: 103 | - name: Checkout 104 | uses: actions/checkout@v4 105 | 106 | - name: Setup Node.js 107 | uses: actions/setup-node@v4 108 | with: 109 | node-version: '20' 110 | 111 | - name: Download artifacts 112 | uses: actions/download-artifact@v3 113 | with: 114 | name: package 115 | path: ~/build 116 | 117 | - name: Configure NPM token 118 | run: npm config set //registry.npmjs.org/:_authToken ${{ secrets.NPM_TOKEN }} 119 | 120 | - name: Publish to NPM 121 | working-directory: /home/runner/build 122 | run: npm publish --verbose 123 | -------------------------------------------------------------------------------- /tests/integration/batch.test.ts: -------------------------------------------------------------------------------- 1 | import makeServer from './drivers/default/server'; 2 | import Post from '../stubs/models/post'; 3 | import { Orion } from '../../src/orion'; 4 | 5 | let server: any; 6 | 7 | beforeEach(() => { 8 | server = makeServer(); 9 | }); 10 | 11 | afterEach(() => { 12 | server.shutdown(); 13 | }); 14 | 15 | describe('Batch tests', () => { 16 | test('saving a couple of models', async () => { 17 | const posts = [ 18 | new Post(), 19 | new Post(), 20 | ] 21 | posts[0].$attributes.title = "First"; 22 | posts[1].$attributes.title = "Second"; 23 | 24 | const res = await Post.$query().batchStore(posts); 25 | 26 | expect(server.schema.posts.all()).toHaveLength(2); 27 | expect(server.schema.posts.find('1').attrs.title).toBe("First") 28 | expect(server.schema.posts.find('2').attrs.title).toBe("Second") 29 | expect(server.schema.posts.find('1').attrs.title).toEqual(res[0].$attributes.title) 30 | expect(server.schema.posts.find('1').attrs.created_at).toEqual(res[0].$attributes.created_at) 31 | expect(server.schema.posts.find('2').attrs.title).toEqual(res[1].$attributes.title) 32 | expect(server.schema.posts.find('2').attrs.created_at).toEqual(res[1].$attributes.created_at) 33 | }); 34 | 35 | test('updating a couple of models', async () => { 36 | const posts = [ 37 | new Post(), 38 | new Post(), 39 | new Post(), 40 | ] 41 | posts[0].$attributes.title = "First"; 42 | posts[1].$attributes.title = "Second"; 43 | posts[2].$attributes.title = "Third"; 44 | 45 | let res = await Post.$query().batchStore(posts); 46 | 47 | res[0].$attributes.title = "NewFirst"; 48 | res[1].$attributes.title = "NewSecond"; 49 | 50 | res = await Post.$query().batchUpdate([res[0],res[1]]); 51 | 52 | expect(res).toHaveLength(2); 53 | expect(server.schema.posts.find('1').attrs.title).toBe("NewFirst") 54 | expect(server.schema.posts.find('2').attrs.title).toBe("NewSecond") 55 | expect(server.schema.posts.find('1').attrs.title).toEqual(res[0].$attributes.title) 56 | expect(server.schema.posts.find('2').attrs.title).toEqual(res[1].$attributes.title) 57 | expect(server.schema.posts.find('3').attrs.title).toEqual("Third"); 58 | 59 | }); 60 | 61 | test('deleting a couple of models', async () => { 62 | const posts = [ 63 | new Post(), 64 | new Post(), 65 | new Post(), 66 | ] 67 | posts[0].$attributes.title = "First"; 68 | posts[1].$attributes.title = "Second"; 69 | posts[2].$attributes.title = "Third"; 70 | 71 | const res = await Post.$query().batchStore(posts); 72 | 73 | const ModelDelete = await Post.$query().batchDelete([res[1].$getKey(), res[2].$getKey()]); 74 | 75 | expect(server.schema.posts.find('1').attrs.deleted_at).toBeUndefined(); 76 | expect(server.schema.posts.find('2').attrs.deleted_at).toBeDefined(); 77 | expect(server.schema.posts.find('3').attrs.deleted_at).toBeDefined(); 78 | expect(server.schema.posts.find('2').attrs.title).toEqual(ModelDelete[0].$attributes.title) 79 | expect(server.schema.posts.find('3').attrs.title).toEqual(ModelDelete[1].$attributes.title) 80 | 81 | 82 | }); 83 | 84 | test('restoring a couple of models', async () => { 85 | const posts = [ 86 | new Post(), 87 | new Post(), 88 | new Post(), 89 | ] 90 | posts[0].$attributes.title = "First"; 91 | posts[1].$attributes.title = "Second"; 92 | posts[2].$attributes.title = "Third"; 93 | 94 | let res = await Post.$query().batchStore(posts); 95 | 96 | // delete ID 2 & 3 97 | const ModelDelete = await Post.$query().batchDelete([res[1].$getKey(), res[2].$getKey()]); 98 | 99 | res = await Post.$query().batchRestore(ModelDelete.map(x => x.$getKey())); 100 | 101 | expect(server.schema.posts.find('1').attrs.deleted_at).toBeFalsy(); 102 | expect(server.schema.posts.find('2').attrs.deleted_at).toBeFalsy(); 103 | expect(server.schema.posts.find('3').attrs.deleted_at).toBeFalsy(); 104 | expect(server.schema.posts.find('2').attrs.title).toEqual(res[0].$attributes.title); 105 | expect(server.schema.posts.find('3').attrs.title).toEqual(res[1].$attributes.title); 106 | 107 | 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/drivers/default/relations/belongsToMany.ts: -------------------------------------------------------------------------------- 1 | import {Model} from '../../../model'; 2 | import {RelationQueryBuilder} from '../builders/relationQueryBuilder'; 3 | import {HttpMethod} from '../enums/httpMethod'; 4 | import {AttachResult} from '../results/attachResult'; 5 | import {ExtractModelAttributesType} from '../../../types/extractModelAttributesType'; 6 | import {DetachResult} from '../results/detachResult'; 7 | import {SyncResult} from '../results/syncResult'; 8 | import {ToggleResult} from '../results/toggleResult'; 9 | import {UpdatePivotResult} from '../results/updatePivotResult'; 10 | import { 11 | ExtractModelPersistedAttributesType 12 | } from '../../../types/extractModelPersistedAttributesType'; 13 | import {ExtractModelRelationsType} from '../../../types/extractModelRelationsType'; 14 | 15 | export class BelongsToMany< 16 | Relation extends Model, 17 | Pivot = Record, 18 | Attributes = ExtractModelAttributesType, 19 | PersistedAttributes = ExtractModelPersistedAttributesType, 20 | Relations = ExtractModelRelationsType 21 | > extends RelationQueryBuilder { 22 | public async attach( 23 | keys: Array, 24 | duplicates: boolean = false 25 | ): Promise { 26 | const response = await this.httpClient.request<{ attached: Array }>( 27 | `/attach`, 28 | HttpMethod.POST, 29 | {duplicates: duplicates ? 1 : 0}, 30 | { 31 | resources: keys, 32 | } 33 | ); 34 | 35 | return new AttachResult(response.data.attached); 36 | } 37 | 38 | public async attachWithFields( 39 | resources: Record, 40 | duplicates: boolean = false 41 | ): Promise { 42 | const response = await this.httpClient.request<{ attached: Array }>( 43 | `/attach`, 44 | HttpMethod.POST, 45 | {duplicates: duplicates ? 1 : 0}, 46 | {resources} 47 | ); 48 | 49 | return new AttachResult(response.data.attached); 50 | } 51 | 52 | public async detach(keys: Array): Promise { 53 | const response = await this.httpClient.request<{ detached: Array }>(`/detach`, HttpMethod.DELETE, null, { 54 | resources: keys, 55 | }); 56 | 57 | return new DetachResult(response.data.detached); 58 | } 59 | 60 | public async detachWithFields(resources: Record): Promise { 61 | const response = await this.httpClient.request<{ detached: Array }>(`/detach`, HttpMethod.DELETE, null, { 62 | resources, 63 | }); 64 | 65 | return new DetachResult(response.data.detached); 66 | } 67 | 68 | public async sync(keys: Array, detaching: boolean = true): Promise { 69 | const response = await this.httpClient.request< 70 | { attached: Array, updated: Array, detached: Array } 71 | >( 72 | `/sync`, 73 | HttpMethod.PATCH, 74 | {detaching: detaching ? 1 : 0}, 75 | { 76 | resources: keys, 77 | } 78 | ); 79 | 80 | return new SyncResult(response.data.attached, response.data.updated, response.data.detached); 81 | } 82 | 83 | public async syncWithFields( 84 | resources: Record, 85 | detaching: boolean = true 86 | ): Promise { 87 | const response = await this.httpClient.request< 88 | { attached: Array, updated: Array, detached: Array } 89 | >( 90 | `/sync`, 91 | HttpMethod.PATCH, 92 | {detaching: detaching ? 1 : 0}, 93 | {resources} 94 | ); 95 | 96 | return new SyncResult(response.data.attached, response.data.updated, response.data.detached); 97 | } 98 | 99 | public async toggle(keys: Array): Promise { 100 | const response = await this.httpClient.request< 101 | { attached: Array, detached: Array } 102 | >(`/toggle`, HttpMethod.PATCH, null, { 103 | resources: keys, 104 | }); 105 | 106 | return new ToggleResult(response.data.attached, response.data.detached); 107 | } 108 | 109 | public async toggleWithFields(resources: Record): Promise { 110 | const response = await this.httpClient.request< 111 | { attached: Array, detached: Array } 112 | >(`/toggle`, HttpMethod.PATCH, null, { 113 | resources, 114 | }); 115 | 116 | return new ToggleResult(response.data.attached, response.data.detached); 117 | } 118 | 119 | public async updatePivot(key: number | string, pivot: Pivot): Promise { 120 | const response = await this.httpClient.request<{ updated: Array }>(`/${key}/pivot`, HttpMethod.PATCH, null, { 121 | pivot, 122 | }); 123 | 124 | return new UpdatePivotResult(response.data.updated); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/orion.ts: -------------------------------------------------------------------------------- 1 | import {AuthDriver} from './drivers/default/enums/authDriver'; 2 | import {HttpClient} from './httpClient'; 3 | import axios, {AxiosInstance, AxiosRequestConfig} from 'axios'; 4 | 5 | export class Orion { 6 | protected static baseUrl: string; 7 | protected static prefix: string; 8 | protected static authDriver: AuthDriver; 9 | protected static token: string | null = null; 10 | 11 | protected static httpClientConfig: AxiosRequestConfig; 12 | protected static makeHttpClientCallback: (() => AxiosInstance) | null = null; 13 | 14 | public static init( 15 | baseUrl: string, 16 | prefix: string = 'api', 17 | authDriver: AuthDriver = AuthDriver.Default, 18 | token?: string 19 | ): void { 20 | Orion.setBaseUrl(baseUrl); 21 | if (token) { 22 | Orion.setToken(token); 23 | } 24 | this.prefix = prefix; 25 | this.authDriver = authDriver; 26 | 27 | this.httpClientConfig = Orion.buildHttpClientConfig(); 28 | } 29 | 30 | public static setBaseUrl(baseUrl: string): Orion { 31 | Orion.baseUrl = baseUrl; 32 | return Orion; 33 | } 34 | 35 | public static getBaseUrl(): string { 36 | return Orion.baseUrl.endsWith('/') ? Orion.baseUrl : `${Orion.baseUrl}/`; 37 | } 38 | 39 | public static setPrefix(prefix: string): Orion { 40 | Orion.prefix = prefix; 41 | return Orion; 42 | } 43 | 44 | public static getPrefix(): string { 45 | return Orion.prefix; 46 | } 47 | 48 | public static setAuthDriver(authDriver: AuthDriver): Orion { 49 | this.authDriver = authDriver; 50 | Orion.httpClientConfig = Orion.buildHttpClientConfig(); 51 | 52 | return Orion; 53 | } 54 | 55 | public static getAuthDriver(): AuthDriver { 56 | return this.authDriver; 57 | } 58 | 59 | public static getApiUrl(): string { 60 | return Orion.getBaseUrl() + Orion.getPrefix(); 61 | } 62 | 63 | public static setToken(token: string): Orion { 64 | Orion.token = token; 65 | Orion.httpClientConfig = Orion.buildHttpClientConfig(); 66 | return Orion; 67 | } 68 | 69 | public static withoutToken(): Orion { 70 | Orion.token = null; 71 | Orion.httpClientConfig = Orion.buildHttpClientConfig(); 72 | return Orion; 73 | } 74 | 75 | public static getToken(): string | null { 76 | return Orion.token; 77 | } 78 | 79 | public static getHttpClientConfig(): AxiosRequestConfig { 80 | return this.httpClientConfig; 81 | } 82 | 83 | public static setHttpClientConfig(config: AxiosRequestConfig): Orion { 84 | this.httpClientConfig = config; 85 | return Orion; 86 | } 87 | 88 | public static makeHttpClient(baseUrl?: string, withPrefix = true): HttpClient { 89 | const client: AxiosInstance = this.makeHttpClientCallback 90 | ? this.makeHttpClientCallback() 91 | : axios.create(); 92 | 93 | if (!baseUrl) { 94 | baseUrl = withPrefix ? Orion.getApiUrl() : Orion.getBaseUrl() 95 | } 96 | 97 | return new HttpClient(baseUrl, client); 98 | } 99 | 100 | public static makeHttpClientUsing(callback: () => AxiosInstance): Orion { 101 | this.makeHttpClientCallback = callback; 102 | 103 | return this; 104 | } 105 | 106 | protected static buildHttpClientConfig(): AxiosRequestConfig { 107 | const config: AxiosRequestConfig = { 108 | withCredentials: Orion.getAuthDriver() === AuthDriver.Sanctum, 109 | }; 110 | 111 | if (Orion.getToken()) { 112 | config.headers = { 113 | Authorization: `Bearer ${Orion.getToken()}`, 114 | }; 115 | } 116 | 117 | return config; 118 | } 119 | 120 | public static async csrf(): Promise { 121 | if (this.authDriver !== AuthDriver.Sanctum) { 122 | throw new Error( 123 | `Current auth driver is set to "${this.authDriver}". Fetching CSRF cookie can only be used with "sanctum" driver.` 124 | ); 125 | } 126 | 127 | const httpClient = Orion.makeHttpClient(); 128 | let response = null; 129 | 130 | try { 131 | response = await httpClient 132 | .getAxios() 133 | .get(`sanctum/csrf-cookie`, { baseURL: Orion.getBaseUrl() }); 134 | } catch (error) { 135 | throw new Error( 136 | `Unable to retrieve XSRF token cookie due to network error. Please ensure that SANCTUM_STATEFUL_DOMAINS and SESSION_DOMAIN environment variables are configured correctly on the API side.` 137 | ); 138 | } 139 | 140 | const xsrfTokenPresent = 141 | document.cookie 142 | .split(';') 143 | .filter((cookie: string) => 144 | cookie.includes(httpClient.getAxios().defaults.xsrfCookieName || 'XSRF-TOKEN') 145 | ).length > 0; 146 | 147 | if (!xsrfTokenPresent) { 148 | console.log(`Response status: ${response.status}`); 149 | console.log(`Response headers:`); 150 | console.log(response.headers); 151 | console.log(`Cookies: ${document.cookie}`); 152 | 153 | throw new Error( 154 | `XSRF token cookie is missing in the response. Please ensure that SANCTUM_STATEFUL_DOMAINS and SESSION_DOMAIN environment variables are configured correctly on the API side.` 155 | ); 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /tests/integration/drivers/default/server.ts: -------------------------------------------------------------------------------- 1 | import { belongsTo, createServer, hasMany, Model as MirageModel } from 'miragejs'; 2 | import { Orion } from '../../../../src/orion'; 3 | import { LaravelSerializer } from './serializer'; 4 | 5 | export default function makeServer() { 6 | return createServer({ 7 | environment: 'test', 8 | 9 | trackRequests: true, 10 | 11 | serializers: { 12 | application: LaravelSerializer, 13 | }, 14 | 15 | models: { 16 | post: MirageModel.extend({ 17 | user: belongsTo('user'), 18 | }), 19 | user: MirageModel.extend({ 20 | posts: hasMany('posts'), 21 | }), 22 | }, 23 | 24 | routes: function () { 25 | this.urlPrefix = 'https://api-mock.test'; 26 | this.namespace = ''; 27 | 28 | this.get('/sanctum/csrf-cookie', () => { 29 | const cookieExpiration = new Date(new Date().getTime() + 24 * 3600 * 1000); 30 | document.cookie = `XSRF-TOKEN=test; path=/; expires=${cookieExpiration.toUTCString()};`; 31 | 32 | return []; 33 | }); 34 | 35 | this.get('/api/posts'); 36 | 37 | this.post('/api/posts', function (schema: any, request) { 38 | const attrs = JSON.parse(request.requestBody); 39 | 40 | return schema.posts.create(attrs); 41 | }); 42 | 43 | this.post('/api/posts/search', function (schema: any, request) { 44 | return schema.posts.all(); 45 | }); 46 | 47 | this.get('/api/posts/:id'); 48 | this.patch('/api/posts/:id', (schema: any, request) => { 49 | const id = request.params.id; 50 | const attrs = JSON.parse(request.requestBody); 51 | 52 | const post = schema.posts.find(id); 53 | 54 | return post.update(attrs); 55 | }); 56 | 57 | this.del('/api/posts/:id', (schema: any, request) => { 58 | const id = request.params.id; 59 | const post = schema.posts.find(id); 60 | 61 | if (request.queryParams.force === 'true') { 62 | post.destroy(); 63 | } else { 64 | post.update({ deleted_at: '2021-01-01' }); 65 | } 66 | 67 | return post; 68 | }); 69 | 70 | this.post('/api/posts/:id/restore', (schema: any, request) => { 71 | const id = request.params.id; 72 | const post = schema.posts.find(id); 73 | 74 | return post.update({ deleted_at: null }); 75 | }); 76 | 77 | this.post('/api/users/:id/posts/associate', (schema: any, request) => { 78 | const userId = request.params.id; 79 | const postId = JSON.parse(request.requestBody).related_key; 80 | const post = schema.posts.find(postId); 81 | 82 | return post.update({ user_id: userId }); 83 | }); 84 | 85 | this.delete('/api/users/:user_id/posts/:post_id/dissociate', (schema: any, request) => { 86 | const postId = request.params.post_id; 87 | const post = schema.posts.find(postId); 88 | 89 | return post.update({ user_id: null }); 90 | }); 91 | 92 | this.post('/api/posts/:id/tags/attach', (schema: any, request) => { 93 | const tagIds = JSON.parse(request.requestBody).resources; 94 | 95 | return { 96 | attached: Array.isArray(tagIds) ? tagIds : Object.keys(tagIds), 97 | }; 98 | }); 99 | 100 | this.delete('/api/posts/:id/tags/detach', (schema: any, request) => { 101 | const tagIds = JSON.parse(request.requestBody).resources; 102 | 103 | return { 104 | detached: Array.isArray(tagIds) ? tagIds : Object.keys(tagIds), 105 | }; 106 | }); 107 | 108 | this.patch('/api/posts/:id/tags/sync', (schema: any, request) => { 109 | const tagIds = JSON.parse(request.requestBody).resources; 110 | 111 | return { 112 | attached: Array.isArray(tagIds) ? tagIds : Object.keys(tagIds), 113 | updated: Array.isArray(tagIds) ? tagIds : Object.keys(tagIds), 114 | detached: Array.isArray(tagIds) ? tagIds : Object.keys(tagIds), 115 | }; 116 | }); 117 | 118 | this.patch('/api/posts/:id/tags/toggle', (schema: any, request) => { 119 | const tagIds = JSON.parse(request.requestBody).resources; 120 | 121 | return { 122 | attached: Array.isArray(tagIds) ? tagIds : Object.keys(tagIds), 123 | detached: Array.isArray(tagIds) ? tagIds : Object.keys(tagIds), 124 | }; 125 | }); 126 | 127 | this.patch('/api/posts/:post_id/tags/:tag_id/pivot', (schema: any, request) => { 128 | return { 129 | updated: [request.params.tag_id], 130 | }; 131 | }); 132 | 133 | this.post('/api/posts/batch', (schema: any, request) => { 134 | const body: { 135 | resources: any[] 136 | } = JSON.parse(request.requestBody); 137 | 138 | const rval: any[] = []; 139 | for (let i = 0; i < body.resources.length; i++) { 140 | rval.push(schema.posts.create(body.resources[i])); 141 | } 142 | 143 | return {data: rval}; 144 | }) 145 | 146 | this.patch('/api/posts/batch', (schema: any, request) => { 147 | const body: { 148 | resources: Record 149 | } = JSON.parse(request.requestBody); 150 | 151 | const rval: any[] = []; 152 | for (const key in body.resources) { 153 | const attrs = body.resources[key]; 154 | 155 | const post = schema.posts.find(key); 156 | 157 | 158 | rval.push(post.update(attrs)); 159 | } 160 | 161 | return {data: rval}; 162 | }) 163 | 164 | this.delete('/api/posts/batch', (schema: any, request) => { 165 | const body: { 166 | resources: number[] 167 | } = JSON.parse(request.requestBody); 168 | 169 | const rval: any[] = []; 170 | for (let i = 0; i < body.resources.length; i++) { 171 | const id = body.resources[i]; 172 | const post = schema.posts.find(id); 173 | 174 | post.update({ deleted_at: '2021-01-01' }); 175 | 176 | rval.push(post); 177 | } 178 | 179 | return {data: rval}; 180 | }) 181 | 182 | this.post('/api/posts/batch/restore', (schema: any, request) => { 183 | const body: { 184 | resources: number[] 185 | } = JSON.parse(request.requestBody); 186 | 187 | const rval: any[] = []; 188 | 189 | for (let i = 0; i < body.resources.length; i++) { 190 | const id = body.resources[i]; 191 | const post = schema.posts.find(id); 192 | 193 | post.update({ deleted_at: null }); 194 | 195 | rval.push(post); 196 | } 197 | 198 | return {data: rval}; 199 | }) 200 | }, 201 | }); 202 | } 203 | 204 | Orion.init('https://api-mock.test'); 205 | -------------------------------------------------------------------------------- /tests/integration/drivers/default/relations/belongsToMany.test.ts: -------------------------------------------------------------------------------- 1 | import Post from '../../../../stubs/models/post'; 2 | import makeServer from '../server'; 3 | import { BelongsToMany } from '../../../../../src/drivers/default/relations/belongsToMany'; 4 | import Tag from '../../../../stubs/models/tag'; 5 | import { AttachResult } from '../../../../../src/drivers/default/results/attachResult'; 6 | import { DetachResult } from '../../../../../src/drivers/default/results/detachResult'; 7 | import { SyncResult } from '../../../../../src/drivers/default/results/syncResult'; 8 | import { ToggleResult } from '../../../../../src/drivers/default/results/toggleResult'; 9 | import { UpdatePivotResult } from '../../../../../src/drivers/default/results/updatePivotResult'; 10 | 11 | let server: any; 12 | 13 | beforeEach(() => { 14 | server = makeServer(); 15 | }); 16 | 17 | afterEach(() => { 18 | server.shutdown(); 19 | }); 20 | 21 | describe('BelongsToMany tests', () => { 22 | type TagAttributes = { 23 | content: string; 24 | }; 25 | 26 | type TagPivot = { 27 | example_pivot_field: string; 28 | }; 29 | 30 | test('attaching resources', async () => { 31 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 32 | 33 | const post = new Post(postEntity.attrs); 34 | 35 | const belongsToManyRelation = new BelongsToMany(Tag, post); 36 | const attachResult = await belongsToManyRelation.attach([2, 5, 7]); 37 | 38 | expect(attachResult).toBeInstanceOf(AttachResult); 39 | expect(attachResult.attached).toStrictEqual([2, 5, 7]); 40 | 41 | const requests = server.pretender.handledRequests; 42 | expect(requests[0].queryParams).toStrictEqual({ duplicates: '0' }); 43 | }); 44 | 45 | test('attaching resources with duplicates', async () => { 46 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 47 | 48 | const post = new Post(postEntity.attrs); 49 | 50 | const belongsToManyRelation = new BelongsToMany(Tag, post); 51 | await belongsToManyRelation.attach([2, 5, 7], true); 52 | 53 | const requests = server.pretender.handledRequests; 54 | expect(requests[0].queryParams).toStrictEqual({ duplicates: '1' }); 55 | }); 56 | 57 | test('attaching resources with fields', async () => { 58 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 59 | 60 | const post = new Post(postEntity.attrs); 61 | 62 | const belongsToManyRelation = new BelongsToMany(Tag, post); 63 | const attachResult = await belongsToManyRelation.attachWithFields({ 64 | 2: { 65 | example_pivot_field: 'value A', 66 | }, 67 | 5: { 68 | example_pivot_field: 'value B', 69 | }, 70 | }); 71 | 72 | expect(attachResult).toBeInstanceOf(AttachResult); 73 | expect(attachResult.attached).toStrictEqual(['2', '5']); 74 | 75 | const requests = server.pretender.handledRequests; 76 | expect(requests[0].queryParams).toStrictEqual({ duplicates: '0' }); 77 | }); 78 | 79 | test('attaching resources with fields with duplicates', async () => { 80 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 81 | 82 | const post = new Post(postEntity.attrs); 83 | 84 | const belongsToManyRelation = new BelongsToMany(Tag, post); 85 | await belongsToManyRelation.attachWithFields( 86 | { 87 | 2: { 88 | example_pivot_field: 'value A', 89 | }, 90 | 5: { 91 | example_pivot_field: 'value B', 92 | }, 93 | }, 94 | true 95 | ); 96 | 97 | const requests = server.pretender.handledRequests; 98 | expect(requests[0].queryParams).toStrictEqual({ duplicates: '1' }); 99 | }); 100 | 101 | test('detaching resources', async () => { 102 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 103 | 104 | const post = new Post(postEntity.attrs); 105 | 106 | const belongsToManyRelation = new BelongsToMany(Tag, post); 107 | const detachResult = await belongsToManyRelation.detach([2, 5, 7]); 108 | 109 | expect(detachResult).toBeInstanceOf(DetachResult); 110 | expect(detachResult.detached).toStrictEqual([2, 5, 7]); 111 | }); 112 | 113 | test('detaching resources with fields', async () => { 114 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 115 | 116 | const post = new Post(postEntity.attrs); 117 | 118 | const belongsToManyRelation = new BelongsToMany(Tag, post); 119 | const detachResult = await belongsToManyRelation.detachWithFields({ 120 | 2: { 121 | example_pivot_field: 'value A', 122 | }, 123 | 5: { 124 | example_pivot_field: 'value B', 125 | }, 126 | }); 127 | 128 | expect(detachResult).toBeInstanceOf(DetachResult); 129 | expect(detachResult.detached).toStrictEqual(['2', '5']); 130 | }); 131 | 132 | test('syncing resources', async () => { 133 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 134 | 135 | const post = new Post(postEntity.attrs); 136 | 137 | const belongsToManyRelation = new BelongsToMany(Tag, post); 138 | const syncResult = await belongsToManyRelation.sync([2, 5, 7]); 139 | 140 | expect(syncResult).toBeInstanceOf(SyncResult); 141 | expect(syncResult.attached).toStrictEqual([2, 5, 7]); 142 | expect(syncResult.updated).toStrictEqual([2, 5, 7]); 143 | expect(syncResult.detached).toStrictEqual([2, 5, 7]); 144 | 145 | const requests = server.pretender.handledRequests; 146 | expect(requests[0].queryParams).toStrictEqual({ detaching: '1' }); 147 | }); 148 | 149 | test('syncing resources without detaching', async () => { 150 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 151 | 152 | const post = new Post(postEntity.attrs); 153 | 154 | const belongsToManyRelation = new BelongsToMany(Tag, post); 155 | await belongsToManyRelation.sync([2, 5, 7], false); 156 | 157 | const requests = server.pretender.handledRequests; 158 | expect(requests[0].queryParams).toStrictEqual({ detaching: '0' }); 159 | }); 160 | 161 | test('syncing resources with fields', async () => { 162 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 163 | 164 | const post = new Post(postEntity.attrs); 165 | 166 | const belongsToManyRelation = new BelongsToMany(Tag, post); 167 | const syncResult = await belongsToManyRelation.syncWithFields({ 168 | 2: { 169 | example_pivot_field: 'value A', 170 | }, 171 | 5: { 172 | example_pivot_field: 'value B', 173 | }, 174 | }); 175 | 176 | expect(syncResult).toBeInstanceOf(SyncResult); 177 | expect(syncResult.attached).toStrictEqual(['2', '5']); 178 | expect(syncResult.updated).toStrictEqual(['2', '5']); 179 | expect(syncResult.detached).toStrictEqual(['2', '5']); 180 | 181 | const requests = server.pretender.handledRequests; 182 | expect(requests[0].queryParams).toStrictEqual({ detaching: '1' }); 183 | }); 184 | 185 | test('syncing resources with fields without detaching', async () => { 186 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 187 | 188 | const post = new Post(postEntity.attrs); 189 | 190 | const belongsToManyRelation = new BelongsToMany(Tag, post); 191 | await belongsToManyRelation.syncWithFields( 192 | { 193 | 2: { 194 | example_pivot_field: 'value A', 195 | }, 196 | 5: { 197 | example_pivot_field: 'value B', 198 | }, 199 | }, 200 | false 201 | ); 202 | 203 | const requests = server.pretender.handledRequests; 204 | expect(requests[0].queryParams).toStrictEqual({ detaching: '0' }); 205 | }); 206 | 207 | test('toggling resources', async () => { 208 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 209 | 210 | const post = new Post(postEntity.attrs); 211 | 212 | const belongsToManyRelation = new BelongsToMany(Tag, post); 213 | const toggleResult = await belongsToManyRelation.toggle([2, 5, 7]); 214 | 215 | expect(toggleResult).toBeInstanceOf(ToggleResult); 216 | expect(toggleResult.attached).toStrictEqual([2, 5, 7]); 217 | expect(toggleResult.detached).toStrictEqual([2, 5, 7]); 218 | }); 219 | 220 | test('toggling resources with fields', async () => { 221 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 222 | 223 | const post = new Post(postEntity.attrs); 224 | 225 | const belongsToManyRelation = new BelongsToMany(Tag, post); 226 | const toggleResult = await belongsToManyRelation.toggleWithFields({ 227 | 2: { 228 | example_pivot_field: 'value A', 229 | }, 230 | 5: { 231 | example_pivot_field: 'value B', 232 | }, 233 | }); 234 | 235 | expect(toggleResult).toBeInstanceOf(ToggleResult); 236 | expect(toggleResult.attached).toStrictEqual(['2', '5']); 237 | expect(toggleResult.detached).toStrictEqual(['2', '5']); 238 | }); 239 | 240 | test('updating resource pivot', async () => { 241 | const postEntity = server.schema.posts.create({ title: 'Test Post' }); 242 | 243 | const post = new Post(postEntity.attrs); 244 | 245 | const belongsToManyRelation = new BelongsToMany(Tag, post); 246 | const updatePivotResult = await belongsToManyRelation.updatePivot(5, { 247 | example_pivot_field: 'value', 248 | }); 249 | 250 | expect(updatePivotResult).toBeInstanceOf(UpdatePivotResult); 251 | expect(updatePivotResult.updated).toStrictEqual(['5']); 252 | }); 253 | }); 254 | -------------------------------------------------------------------------------- /tests/integration/drivers/default/builders/queryBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import { QueryBuilder } from '../../../../../src/drivers/default/builders/queryBuilder'; 2 | import Post from '../../../../stubs/models/post'; 3 | import makeServer from '../server'; 4 | import { FilterOperator } from '../../../../../src/drivers/default/enums/filterOperator'; 5 | import { FilterType } from '../../../../../src/drivers/default/enums/filterType'; 6 | import { SortDirection } from '../../../../../src/drivers/default/enums/sortDirection'; 7 | import User from '../../../../stubs/models/user'; 8 | 9 | let server: any; 10 | 11 | beforeEach(() => { 12 | server = makeServer(); 13 | }); 14 | 15 | afterEach(() => { 16 | server.shutdown(); 17 | }); 18 | 19 | describe('QueryBuilder tests', () => { 20 | type PostAttributes = { 21 | title: string; 22 | }; 23 | 24 | test('retrieving a paginated list of resources', async () => { 25 | server.schema.posts.create({ title: 'Test Post A' }); 26 | server.schema.posts.create({ title: 'Test Post B' }); 27 | 28 | const queryBuilder = new QueryBuilder(Post); 29 | const results = await queryBuilder.get(2, 5); 30 | 31 | results.forEach((result) => { 32 | expect(result).toBeInstanceOf(Post); 33 | }); 34 | 35 | expect(results[0].$attributes).toStrictEqual({ id: '1', title: 'Test Post A' }); 36 | expect(results[1].$attributes).toStrictEqual({ id: '2', title: 'Test Post B' }); 37 | 38 | expect(results.length).toBe(2); 39 | 40 | const requests = server.pretender.handledRequests; 41 | expect(requests[0].queryParams).toStrictEqual({ limit: '2', page: '5' }); 42 | }); 43 | 44 | test('retrieving a paginated list of only trashed resources', async () => { 45 | const queryBuilder = new QueryBuilder(Post); 46 | await queryBuilder.onlyTrashed().get(); 47 | 48 | const requests = server.pretender.handledRequests; 49 | expect(requests[0].queryParams).toStrictEqual({ limit: '15', page: '1', only_trashed: 'true' }); 50 | }); 51 | 52 | test('retrieving a paginated list resources with trashed ones', async () => { 53 | const queryBuilder = new QueryBuilder(Post); 54 | await queryBuilder.withTrashed().get(); 55 | 56 | const requests = server.pretender.handledRequests; 57 | expect(requests[0].queryParams).toStrictEqual({ limit: '15', page: '1', with_trashed: 'true' }); 58 | }); 59 | 60 | test('retrieving a paginated list resources with included relations', async () => { 61 | const queryBuilder = new QueryBuilder(Post); 62 | await queryBuilder.with(['user']).get(); 63 | const requests = server.pretender.handledRequests; 64 | expect(requests[0].queryParams).toStrictEqual({ 65 | limit: '15', 66 | page: '1', 67 | include: 'user' 68 | }); 69 | }); 70 | 71 | test('retrieving a paginated list resources with included aggregates', async () => { 72 | const queryBuilder = new QueryBuilder(Post); 73 | await queryBuilder 74 | .withAvg({ 75 | column: 'id', 76 | relation: 'tags' 77 | }) 78 | .withMin({ 79 | column: 'id', 80 | relation: 'tags' 81 | }) 82 | .withMax({ 83 | column: 'id', 84 | relation: 'tags' 85 | }) 86 | .withSum({ 87 | column: 'id', 88 | relation: 'tags' 89 | }) 90 | .withCount('tags') 91 | .withExists('tags') 92 | .get(); 93 | 94 | const requests = server.pretender.handledRequests; 95 | expect(requests[0].queryParams).toStrictEqual({ 96 | limit: '15', 97 | page: '1', 98 | with_avg: 'tags.id', 99 | with_min: 'tags.id', 100 | with_max: 'tags.id', 101 | with_sum: 'tags.id', 102 | with_count: 'tags', 103 | with_exists: 'tags' 104 | }); 105 | }); 106 | 107 | test('searching for resources', async () => { 108 | server.schema.posts.create({ title: 'Test Post A' }); 109 | server.schema.posts.create({ title: 'Test Post B' }); 110 | 111 | const queryBuilder = new QueryBuilder(Post); 112 | const results = await queryBuilder 113 | .scope('test scope', [1, 2, 3]) 114 | .filter('test field', FilterOperator.GreaterThanOrEqual, 'test value', FilterType.Or) 115 | .lookFor('test keyword') 116 | .sortBy('test field', SortDirection.Desc) 117 | .search(); 118 | 119 | results.forEach((result) => { 120 | expect(result).toBeInstanceOf(Post); 121 | }); 122 | 123 | expect(results[0].$attributes).toStrictEqual({ id: '1', title: 'Test Post A' }); 124 | expect(results[1].$attributes).toStrictEqual({ id: '2', title: 'Test Post B' }); 125 | 126 | const requests = server.pretender.handledRequests; 127 | const searchParameters = { 128 | scopes: [{ name: 'test scope', parameters: [1, 2, 3] }], 129 | filters: [ 130 | { 131 | field: 'test field', 132 | operator: FilterOperator.GreaterThanOrEqual, 133 | value: 'test value', 134 | type: FilterType.Or 135 | } 136 | ], 137 | search: { value: 'test keyword' }, 138 | sort: [{ field: 'test field', direction: SortDirection.Desc }] 139 | }; 140 | expect(JSON.parse(requests[0].requestBody)).toStrictEqual(searchParameters); 141 | }); 142 | 143 | test('searching for only trashed resources', async () => { 144 | const queryBuilder = new QueryBuilder(Post); 145 | await queryBuilder.onlyTrashed().search(); 146 | 147 | const requests = server.pretender.handledRequests; 148 | expect(requests[0].queryParams).toStrictEqual({ limit: '15', page: '1', only_trashed: 'true' }); 149 | }); 150 | 151 | test('searching for resources with trashed ones', async () => { 152 | const queryBuilder = new QueryBuilder(Post); 153 | await queryBuilder.withTrashed().search(); 154 | 155 | const requests = server.pretender.handledRequests; 156 | expect(requests[0].queryParams).toStrictEqual({ limit: '15', page: '1', with_trashed: 'true' }); 157 | }); 158 | 159 | test('searching for resources with included relations', async () => { 160 | const queryBuilder = new QueryBuilder(Post); 161 | await queryBuilder.with(['user']).search(); 162 | 163 | const requests = server.pretender.handledRequests; 164 | expect(requests[0].queryParams).toStrictEqual({ 165 | limit: '15', 166 | page: '1', 167 | include: 'user' 168 | }); 169 | }); 170 | 171 | test('storing a resource', async () => { 172 | const queryBuilder = new QueryBuilder(Post); 173 | const post = await queryBuilder.store({ 174 | title: 'Test Post' 175 | }); 176 | 177 | expect(post).toBeInstanceOf(Post); 178 | expect(post.$attributes).toStrictEqual({ id: '1', title: 'Test Post' }); 179 | expect(server.schema.posts.find('1').attrs.title).toBe('Test Post'); 180 | }); 181 | 182 | test('storing a resource and getting its relations', async () => { 183 | const queryBuilder = new QueryBuilder(Post); 184 | const post = await queryBuilder.with(['user']).store({ 185 | title: 'Test Post' 186 | }); 187 | 188 | expect(post).toBeInstanceOf(Post); 189 | expect(post.$attributes).toStrictEqual({ id: '1', title: 'Test Post' }); 190 | expect(server.schema.posts.find('1').attrs.title).toBe('Test Post'); 191 | 192 | const requests = server.pretender.handledRequests; 193 | expect(requests[0].queryParams).toStrictEqual({ include: 'user' }); 194 | }); 195 | 196 | test('retrieving a resource', async () => { 197 | server.schema.posts.create({ title: 'Test Post' }); 198 | 199 | const queryBuilder = new QueryBuilder(Post); 200 | const post = await queryBuilder.find('1'); 201 | 202 | expect(post).toBeInstanceOf(Post); 203 | expect(post.$attributes).toStrictEqual({ id: '1', title: 'Test Post' }); 204 | }); 205 | 206 | test('retrieving a soft deleted resource', async () => { 207 | server.schema.posts.create({ title: 'Test Post' }); 208 | 209 | const queryBuilder = new QueryBuilder(Post); 210 | await queryBuilder.withTrashed().find('1'); 211 | 212 | const requests = server.pretender.handledRequests; 213 | expect(requests[0].queryParams).toStrictEqual({ with_trashed: 'true' }); 214 | }); 215 | 216 | test('retrieving a resource with included relations', async () => { 217 | server.schema.posts.create({ title: 'Test Post' }); 218 | 219 | const queryBuilder = new QueryBuilder(Post); 220 | const post = await queryBuilder.with(['user']).find('1'); 221 | 222 | expect(post).toBeInstanceOf(Post); 223 | expect(post.$attributes).toStrictEqual({ id: '1', title: 'Test Post' }); 224 | 225 | const requests = server.pretender.handledRequests; 226 | expect(requests[0].queryParams).toStrictEqual({ include: 'user' }); 227 | }); 228 | 229 | test('updating a resource', async () => { 230 | server.schema.posts.create({ title: 'Test Post' }); 231 | 232 | const queryBuilder = new QueryBuilder(Post); 233 | const post = await queryBuilder.update('1', { 234 | title: 'Updated Post' 235 | }); 236 | 237 | expect(post).toBeInstanceOf(Post); 238 | expect(post.$attributes).toStrictEqual({ id: '1', title: 'Updated Post' }); 239 | expect(server.schema.posts.find('1').attrs.title).toBe('Updated Post'); 240 | }); 241 | 242 | test('updating a resource with included relations', async () => { 243 | server.schema.posts.create({ title: 'Test Post' }); 244 | 245 | const queryBuilder = new QueryBuilder(Post); 246 | const post = await queryBuilder.with(['user']).update('1', { 247 | title: 'Updated Post' 248 | }); 249 | 250 | expect(post).toBeInstanceOf(Post); 251 | expect(post.$attributes).toStrictEqual({ id: '1', title: 'Updated Post' }); 252 | expect(server.schema.posts.find('1').attrs.title).toBe('Updated Post'); 253 | 254 | const requests = server.pretender.handledRequests; 255 | expect(requests[0].queryParams).toStrictEqual({ include: 'user' }); 256 | }); 257 | 258 | test('updating a soft deleted resource', async () => { 259 | server.schema.posts.create({ title: 'Test Post' }); 260 | 261 | const queryBuilder = new QueryBuilder(Post); 262 | const post = await queryBuilder.withTrashed().update('1', { 263 | title: 'Updated Post' 264 | }); 265 | 266 | expect(post).toBeInstanceOf(Post); 267 | expect(post.$attributes).toStrictEqual({ id: '1', title: 'Updated Post' }); 268 | expect(server.schema.posts.find('1').attrs.title).toBe('Updated Post'); 269 | 270 | const requests = server.pretender.handledRequests; 271 | expect(requests[0].queryParams).toStrictEqual({ with_trashed: 'true' }); 272 | }); 273 | 274 | test('trashing a resource', async () => { 275 | server.schema.posts.create({ title: 'Test Post' }); 276 | 277 | const queryBuilder = new QueryBuilder(Post); 278 | const post = await queryBuilder.destroy('1'); 279 | 280 | expect(post).toBeInstanceOf(Post); 281 | expect(post.$attributes).toStrictEqual({ 282 | id: '1', 283 | title: 'Test Post', 284 | deleted_at: '2021-01-01' 285 | }); 286 | expect(server.schema.posts.find('1').attrs.deleted_at).toBeDefined(); 287 | }); 288 | 289 | test('trashing a resource with included relations', async () => { 290 | server.schema.posts.create({ title: 'Test Post' }); 291 | 292 | const queryBuilder = new QueryBuilder(Post); 293 | const post = await queryBuilder.with(['user']).destroy('1'); 294 | 295 | expect(post).toBeInstanceOf(Post); 296 | expect(post.$attributes).toStrictEqual({ 297 | id: '1', 298 | title: 'Test Post', 299 | deleted_at: '2021-01-01' 300 | }); 301 | expect(server.schema.posts.find('1').attrs.deleted_at).toBeDefined(); 302 | 303 | const requests = server.pretender.handledRequests; 304 | expect(requests[0].queryParams).toStrictEqual({ force: 'false', include: 'user' }); 305 | }); 306 | 307 | test('restoring a resource', async () => { 308 | server.schema.posts.create({ title: 'Test Post', deleted_at: Date.now() }); 309 | 310 | const queryBuilder = new QueryBuilder(Post); 311 | const post = await queryBuilder.restore('1'); 312 | 313 | expect(post).toBeInstanceOf(Post); 314 | expect(post.$attributes).toStrictEqual({ id: '1', title: 'Test Post', deleted_at: null }); 315 | expect(server.schema.posts.find('1').attrs.deleted_at).toBeNull(); 316 | }); 317 | 318 | test('restoring a resource with included relations', async () => { 319 | server.schema.posts.create({ title: 'Test Post', deleted_at: Date.now() }); 320 | 321 | const queryBuilder = new QueryBuilder(Post); 322 | const post = await queryBuilder.with(['user']).restore('1'); 323 | 324 | expect(post).toBeInstanceOf(Post); 325 | expect(post.$attributes).toStrictEqual({ id: '1', title: 'Test Post', deleted_at: null }); 326 | expect(server.schema.posts.find('1').attrs.deleted_at).toBeNull(); 327 | 328 | const requests = server.pretender.handledRequests; 329 | expect(requests[0].queryParams).toStrictEqual({ include: 'user' }); 330 | }); 331 | 332 | test('force deleting a resource', async () => { 333 | server.schema.posts.create({ title: 'Test Post' }); 334 | 335 | const queryBuilder = new QueryBuilder(Post); 336 | const post = await queryBuilder.destroy('1', true); 337 | 338 | expect(post).toBeInstanceOf(Post); 339 | expect(post.$attributes).toStrictEqual({ id: '1', title: 'Test Post' }); 340 | expect(server.schema.posts.find('1')).toBeNull(); 341 | 342 | const requests = server.pretender.handledRequests; 343 | expect(requests[0].queryParams).toStrictEqual({ force: 'true' }); 344 | }); 345 | 346 | test('hydrating model with attributes and relations', async () => { 347 | const queryBuilder = new QueryBuilder(Post); 348 | const post = queryBuilder.hydrate({ 349 | id: 1, 350 | title: 'test', 351 | updated_at: '2021-02-01', 352 | created_at: '2021-02-01', 353 | user: ({ 354 | id: 1, 355 | name: 'Test User', 356 | updated_at: '2021-02-01', 357 | created_at: '2021-02-01' 358 | } as unknown) as User 359 | }); 360 | 361 | expect(post).toBeInstanceOf(Post); 362 | expect(post.$attributes).toStrictEqual({ 363 | id: 1, 364 | title: 'test', 365 | updated_at: '2021-02-01', 366 | created_at: '2021-02-01' 367 | }); 368 | expect(post.$relations.user).toBeInstanceOf(User); 369 | expect(post.$relations.user.$attributes).toStrictEqual({ 370 | id: 1, 371 | name: 'Test User', 372 | updated_at: '2021-02-01', 373 | created_at: '2021-02-01' 374 | }); 375 | }); 376 | }); 377 | -------------------------------------------------------------------------------- /src/drivers/default/builders/queryBuilder.ts: -------------------------------------------------------------------------------- 1 | import { HttpMethod } from '../enums/httpMethod'; 2 | import { Model } from '../../../model'; 3 | import { ModelConstructor } from '../../../contracts/modelConstructor'; 4 | import { Scope } from '../scope'; 5 | import { Filter } from '../filter'; 6 | import { FilterOperator } from '../enums/filterOperator'; 7 | import { FilterType } from '../enums/filterType'; 8 | import { Sorter } from '../sorter'; 9 | import { SortDirection } from '../enums/sortDirection'; 10 | import { UrlBuilder } from '../../../builders/urlBuilder'; 11 | import { ExtractModelAttributesType } from '../../../types/extractModelAttributesType'; 12 | import { 13 | ExtractModelPersistedAttributesType 14 | } from '../../../types/extractModelPersistedAttributesType'; 15 | import { ExtractModelRelationsType } from '../../../types/extractModelRelationsType'; 16 | import { HttpClient } from '../../../httpClient'; 17 | import { AxiosResponse } from 'axios'; 18 | import { Orion } from '../../../orion'; 19 | import { ExtractModelKeyType } from '../../../types/extractModelKeyType'; 20 | import { AggregateItem } from '../../../types/AggregateItem'; 21 | import { ModelRelations } from '../../../types/ModelRelations'; 22 | 23 | export class QueryBuilder< 24 | M extends Model, 25 | Attributes = ExtractModelAttributesType, 26 | PersistedAttributes = ExtractModelPersistedAttributesType, 27 | Relations = ExtractModelRelationsType, 28 | Key = ExtractModelKeyType, 29 | AllAttributes = Attributes & PersistedAttributes 30 | > { 31 | protected baseUrl: string; 32 | protected modelConstructor: ModelConstructor; 33 | protected httpClient: HttpClient; 34 | 35 | protected includes: string[] = []; 36 | protected fetchTrashed: boolean = false; 37 | protected fetchOnlyTrashed: boolean = false; 38 | protected withCountRelations: ModelRelations[] = []; 39 | protected withExistsRelations: ModelRelations[] = []; 40 | protected withAvgRelations: AggregateItem[] = []; 41 | protected withSumRelations: AggregateItem[] = []; 42 | protected withMinRelations: AggregateItem[] = []; 43 | protected withMaxRelations: AggregateItem[] = []; 44 | 45 | protected scopes: Array = []; 46 | protected filters: Array = []; 47 | protected sorters: Array = []; 48 | protected searchValue?: string; 49 | 50 | constructor( 51 | modelConstructor: ModelConstructor, 52 | baseUrl?: string 53 | ) { 54 | if (baseUrl) { 55 | this.baseUrl = baseUrl; 56 | } else { 57 | this.baseUrl = UrlBuilder.getResourceBaseUrl(modelConstructor); 58 | } 59 | 60 | this.modelConstructor = modelConstructor; 61 | this.httpClient = Orion.makeHttpClient(this.baseUrl); 62 | } 63 | 64 | public async get(limit: number = 15, page: number = 1): Promise> { 65 | const response = await this.httpClient.request<{ data: Array }>( 66 | '', 67 | HttpMethod.GET, 68 | this.prepareQueryParams({ limit, page }) 69 | ); 70 | 71 | return response.data.data.map((attributes: AllAttributes & Relations) => { 72 | return this.hydrate(attributes, response); 73 | }); 74 | } 75 | 76 | public async search(limit: number = 15, page: number = 1): Promise> { 77 | const response = await this.httpClient.request<{ data: Array }>( 78 | '/search', 79 | HttpMethod.POST, 80 | this.prepareQueryParams({ limit, page }), 81 | { 82 | scopes: this.scopes, 83 | filters: this.filters, 84 | search: { value: this.searchValue }, 85 | sort: this.sorters 86 | } 87 | ); 88 | 89 | return response.data.data.map((attributes: AllAttributes & Relations) => { 90 | return this.hydrate(attributes, response); 91 | }); 92 | } 93 | 94 | public async find(key: Key): Promise { 95 | const response = await this.httpClient.request<{ data: AllAttributes & Relations }>( 96 | `/${key}`, 97 | HttpMethod.GET, 98 | this.prepareQueryParams() 99 | ); 100 | 101 | return this.hydrate(response.data.data, response); 102 | } 103 | 104 | public async store(attributes: Attributes): Promise { 105 | const response = await this.httpClient.request<{ data: AllAttributes & Relations }>( 106 | '', 107 | HttpMethod.POST, 108 | this.prepareQueryParams(), 109 | attributes as Record 110 | ); 111 | 112 | return this.hydrate(response.data.data, response); 113 | } 114 | 115 | public async batchStore(items: M[]): Promise { 116 | const data = { 117 | resources: items.map(x => x.$attributes) 118 | }; 119 | 120 | const response = await this.httpClient.request<{ data: Array }>( 121 | `/batch`, 122 | HttpMethod.POST, 123 | null, 124 | data 125 | ); 126 | 127 | return response.data.data.map((attributes) => { 128 | return this.hydrate(attributes, response); 129 | }); 130 | } 131 | 132 | public async update(key: Key, attributes: Attributes): Promise { 133 | const response = await this.httpClient.request<{ data: AllAttributes & Relations }>( 134 | `/${key}`, 135 | HttpMethod.PATCH, 136 | this.prepareQueryParams(), 137 | attributes as Record 138 | ); 139 | 140 | return this.hydrate(response.data.data, response); 141 | } 142 | 143 | public async batchUpdate(items: M[]): Promise { 144 | const data = { 145 | resources: {} 146 | }; 147 | items.forEach((v) => data.resources[v.$getKey()] = v.$attributes); 148 | 149 | const response = await this.httpClient.request<{ data: Array }>( 150 | `batch`, 151 | HttpMethod.PATCH, 152 | null, 153 | data 154 | ); 155 | 156 | return response.data.data.map((attributes: AllAttributes & Relations) => { 157 | return this.hydrate(attributes, response); 158 | }); 159 | } 160 | 161 | public async destroy(key: Key, force: boolean = false): Promise { 162 | const response = await this.httpClient.request<{ data: AllAttributes & Relations }>( 163 | `/${key}`, 164 | HttpMethod.DELETE, 165 | this.prepareQueryParams({ force }) 166 | ); 167 | 168 | return this.hydrate(response.data.data, response); 169 | } 170 | 171 | public async batchDelete(items: Key[]): Promise { 172 | if (!items.length) 173 | return []; 174 | 175 | const data = { 176 | resources: items 177 | }; 178 | 179 | const response = await this.httpClient.request<{ data: Array }>( 180 | `/batch`, 181 | HttpMethod.DELETE, 182 | null, 183 | data 184 | ); 185 | 186 | return response.data.data.map((attributes: AllAttributes & Relations) => { 187 | return this.hydrate(attributes, response); 188 | }); 189 | } 190 | 191 | public async restore(key: Key): Promise { 192 | const response = await this.httpClient.request<{ data: AllAttributes & Relations }>( 193 | `/${key}/restore`, 194 | HttpMethod.POST, 195 | this.prepareQueryParams() 196 | ); 197 | 198 | return this.hydrate(response.data.data, response); 199 | } 200 | 201 | public async batchRestore(items: Key[]): Promise { 202 | const data = { 203 | resources: items 204 | }; 205 | 206 | const response = await this.httpClient.request<{ data: Array }>( 207 | `/batch/restore`, 208 | HttpMethod.POST, 209 | null, 210 | data 211 | ); 212 | 213 | return response.data.data.map((attributes: AllAttributes & Relations) => { 214 | return this.hydrate(attributes, response); 215 | }); 216 | } 217 | 218 | 219 | public with(relations: ModelRelations[]): this { 220 | this.includes = relations; 221 | 222 | return this; 223 | } 224 | 225 | public withTrashed(): this { 226 | this.fetchTrashed = true; 227 | 228 | return this; 229 | } 230 | 231 | public onlyTrashed(): this { 232 | this.fetchOnlyTrashed = true; 233 | 234 | return this; 235 | } 236 | 237 | public scope(name: string, parameters: Array = []): this { 238 | this.scopes.push(new Scope(name, parameters)); 239 | 240 | return this; 241 | } 242 | 243 | public filter(field: string, operator: FilterOperator, value: any, type?: FilterType): this { 244 | this.filters.push(new Filter(field, operator, value, type)); 245 | 246 | return this; 247 | } 248 | 249 | public sortBy(field: string, direction: SortDirection = SortDirection.Asc): this { 250 | this.sorters.push(new Sorter(field, direction)); 251 | 252 | return this; 253 | } 254 | 255 | public lookFor(value: string): this { 256 | this.searchValue = value; 257 | 258 | return this; 259 | } 260 | 261 | public hydrate(raw: AllAttributes & Relations, response?: AxiosResponse): M { 262 | const model = new this.modelConstructor(); 263 | 264 | for (const field of Object.keys(raw as Record)) { 265 | const rawValue = raw[field]; 266 | 267 | if (typeof model[field] === 'function') { 268 | const relationQueryBuilder: QueryBuilder = model[field](); 269 | 270 | if (Array.isArray(rawValue)) { 271 | model.$relations[field] = rawValue.map((rawRelation: Record) => { 272 | return relationQueryBuilder.hydrate(rawRelation, response); 273 | }); 274 | } else { 275 | if (rawValue) { 276 | model.$relations[field] = relationQueryBuilder.hydrate(rawValue, response); 277 | } else { 278 | model.$relations[field] = rawValue; 279 | } 280 | } 281 | } else { 282 | model.$attributes[field] = rawValue; 283 | } 284 | } 285 | 286 | model.$response = response; 287 | 288 | return model; 289 | } 290 | 291 | /** 292 | * Include the count of the specified relations. 293 | * The relations need to be whitelisted in the controller. 294 | * @link https://tailflow.github.io/laravel-orion-docs/v2.x/guide/search.html#aggregates 295 | */ 296 | public withCount(relations: ModelRelations[] | ModelRelations): this { 297 | if (!Array.isArray(relations)) { 298 | relations = [relations]; 299 | } 300 | 301 | this.withCountRelations.push(...relations); 302 | 303 | return this; 304 | } 305 | 306 | /** 307 | * Include the exists of the specified relations. 308 | * The relations need to be whitelisted in the controller. 309 | * @link https://tailflow.github.io/laravel-orion-docs/v2.x/guide/search.html#aggregates 310 | * @param relations 311 | */ 312 | public withExists(relations: ModelRelations[] | ModelRelations): this { 313 | if (!Array.isArray(relations)) { 314 | relations = [relations]; 315 | } 316 | 317 | this.withExistsRelations.push(...relations); 318 | 319 | return this; 320 | } 321 | 322 | /** 323 | * Include the avg of the specified relations. 324 | * The relations need to be whitelisted in the controller. 325 | * @link https://tailflow.github.io/laravel-orion-docs/v2.x/guide/search.html#aggregates 326 | * @param relations 327 | */ 328 | public withAvg(relations: AggregateItem[] | AggregateItem): this { 329 | if (!Array.isArray(relations)) { 330 | relations = [relations]; 331 | } 332 | 333 | this.withAvgRelations.push(...relations); 334 | 335 | return this; 336 | } 337 | 338 | /** 339 | * Include the sum of the specified relations. 340 | * The relations need to be whitelisted in the controller. 341 | * @link https://tailflow.github.io/laravel-orion-docs/v2.x/guide/search.html#aggregates 342 | * @param relations 343 | */ 344 | public withSum(relations: AggregateItem[] | AggregateItem): this { 345 | if (!Array.isArray(relations)) { 346 | relations = [relations]; 347 | } 348 | 349 | this.withSumRelations.push(...relations); 350 | 351 | return this; 352 | } 353 | 354 | /** 355 | * Include the min of the specified relations. 356 | * The relations need to be whitelisted in the controller. 357 | * @link https://tailflow.github.io/laravel-orion-docs/v2.x/guide/search.html#aggregates 358 | * @param relations 359 | */ 360 | public withMin(relations: AggregateItem[] | AggregateItem): this { 361 | if (!Array.isArray(relations)) { 362 | relations = [relations]; 363 | } 364 | 365 | this.withMinRelations.push(...relations); 366 | 367 | return this; 368 | } 369 | 370 | /** 371 | * Include the max of the specified relations. 372 | * The relations need to be whitelisted in the controller. 373 | * @link https://tailflow.github.io/laravel-orion-docs/v2.x/guide/search.html#aggregates 374 | * @param relations 375 | */ 376 | public withMax(relations: AggregateItem[] | AggregateItem): this { 377 | if (!Array.isArray(relations)) { 378 | relations = [relations]; 379 | } 380 | 381 | this.withMaxRelations.push(...relations); 382 | 383 | return this; 384 | } 385 | 386 | public getHttpClient(): HttpClient { 387 | return this.httpClient; 388 | } 389 | 390 | protected prepareQueryParams(operationParams: any = {}): any { 391 | if (this.fetchOnlyTrashed) { 392 | operationParams.only_trashed = true; 393 | } 394 | 395 | if (this.fetchTrashed) { 396 | operationParams.with_trashed = true; 397 | } 398 | 399 | if (this.includes.length > 0) { 400 | operationParams.include = this.includes.join(','); 401 | } 402 | 403 | if (this.withCountRelations.length > 0) { 404 | operationParams.with_count = this.withCountRelations.join(','); 405 | } 406 | 407 | if (this.withExistsRelations.length > 0) { 408 | operationParams.with_exists = this.withExistsRelations.join(','); 409 | } 410 | 411 | if (this.withAvgRelations.length > 0) { 412 | operationParams.with_avg = this.withAvgRelations.map((item) => { 413 | return `${item.relation}.${item.column}`; 414 | }).join(','); 415 | } 416 | 417 | if (this.withSumRelations.length > 0) { 418 | operationParams.with_sum = this.withSumRelations.map((item) => { 419 | return `${item.relation}.${item.column}`; 420 | }).join(','); 421 | } 422 | 423 | if (this.withMinRelations.length > 0) { 424 | operationParams.with_min = this.withMinRelations.map((item) => { 425 | item.relation; 426 | return `${item.relation}.${item.column}`; 427 | }).join(','); 428 | } 429 | 430 | if (this.withMaxRelations.length > 0) { 431 | operationParams.with_max = this.withMaxRelations.map((item) => { 432 | return `${item.relation}.${item.column}`; 433 | }).join(','); 434 | } 435 | 436 | 437 | return operationParams; 438 | } 439 | } 440 | --------------------------------------------------------------------------------