diff --git a/docs/components/content/app/AgoliaDocSearch.client.vue b/docs/components/content/app/AgoliaDocSearch.client.vue deleted file mode 100644 index 65c9f77ff..000000000 --- a/docs/components/content/app/AgoliaDocSearch.client.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - - - diff --git a/docs/content/1.guide/1.getting-started/2.usage.md b/docs/content/1.guide/1.getting-started/2.usage.md index 40178d586..6e55cba58 100644 --- a/docs/content/1.guide/1.getting-started/2.usage.md +++ b/docs/content/1.guide/1.getting-started/2.usage.md @@ -1,7 +1,3 @@ ---- -description: '' ---- - # Usage ## Standard diff --git a/docs/content/3.plugins/1.introduction.md b/docs/content/3.plugins/1.introduction.md new file mode 100644 index 000000000..5b57e11eb --- /dev/null +++ b/docs/content/3.plugins/1.introduction.md @@ -0,0 +1,40 @@ +# Introduction + +Pinia ORM can also be extended by a plugin system which you can use to extend the +`Repository` or the global `config` + +## Writing a custom plugin + +Use `definePiniaOrmPlugin` to create a custom Pinia ORM plugin. The context option gives you +`model`, `repository` and `config` which you can edit + +````ts{piniaOrmPlugin.ts} +export default definePiniaOrmPlugin((context) => { + context.config.apiConfig = 'test' + return context +}) +```` + +Now add your custom plugin to the Pinia ORM instance: + +````ts +import { createPinia, setActivePinia } from 'pinia' +import { createORM } from 'pinia-orm' +import { createApp } from 'vue' +import { piniaOrmPlugin } from './plugins' + +const app = createApp({}) +const pinia = createPinia() +const piniaOrm = createORM() +piniaOrm().use(piniaOrmPlugin) +pinia.use(piniaOrm) +app.use(pinia) +setActivePinia(pinia) +```` + +Now everytime e.g. you use `useRepo` which uses the global config you can do this + +````ts +console.log(useRepo(User).config.apiConfig) +// 'test' +```` diff --git a/docs/content/3.plugins/2.axios/1.guide/1.setup.md b/docs/content/3.plugins/2.axios/1.guide/1.setup.md new file mode 100644 index 000000000..3cfe8efd6 --- /dev/null +++ b/docs/content/3.plugins/2.axios/1.guide/1.setup.md @@ -0,0 +1,54 @@ +# Setup Axios + +This plugin gives you useful functions which is extending the `Repository` + +## Install Pinia ORM Axios Plugin + +::code-group + ```bash [Yarn] + yarn add @pinia-orm/axios + ``` + ```bash [NPM] + npm install @pinia-orm/axios --save + ``` + ```bash [PNPM] + pnpm add @pinia-orm/axios + ``` +:: + +## Adding the plugin to your pinia ORM store + +You have to options here to use the plugin. Either you use `createPiniaOrmPluginAxios(options?: PinaOrmPluginOptions)` +or you use `pinaOrmPluginAxios`. It depends if you want to pass options on initialization or later. + +::code-group + ```js{}[Vue3] + import { createPinia } from 'pinia' + import { createORM } from 'pinia-orm' + import { createPiniaOrmPluginAxios } from '@pinia-orm/axios' + import axios form 'axios' + + const pinia = createPinia() + const piniaOrm = createORM() + piniaOrm().use(createPiniaOrmPluginAxios({ + axios + })) + pinia.use(piniaOrm) + ``` + ```js{}[Vue2] + import { createPinia, PiniaVuePlugin } from 'pinia' + import { createORM } from 'pinia-orm' + import { createPiniaOrmPluginAxios } from '@pinia-orm/axios' + import axios form 'axios' + + Vue.use(PiniaVuePlugin) + const pinia = createPinia() + const piniaOrm = createORM() + piniaOrm().use(createPiniaOrmPluginAxios({ + axios + })) + pinia.use(piniaOrm) + ``` +:: + + diff --git a/docs/content/3.plugins/2.axios/1.guide/2.configuration.md b/docs/content/3.plugins/2.axios/1.guide/2.configuration.md new file mode 100644 index 000000000..5bd034c5c --- /dev/null +++ b/docs/content/3.plugins/2.axios/1.guide/2.configuration.md @@ -0,0 +1,181 @@ +# Configurations + +Pinia ORM Axios plugin comes with various options to control request behavior. These options can be configured in three common places: + +- **Globally** - options can defined during installation +- **Model** - options can be defined on a per-model basis +- **Request** - options can be defined on a per-request basis + +Any axios options are permitted alongside any plugin options. Options are inherited in the same order, i.e. Global configuration is merged and preceded by Model configuration which is then merged and preceded by any Request configuration. + +### Global Configuration + +Options can be defined during plugin installation by passing an object as the second argument of the Pinia ORM `use()` method. At minimum, the axios instance is required while any other option is entirely optional. + +The following example configures the plugin with an axios instance (required), the `baseURL` option, and some common `headers` that all requests will inherit: + +```js +import axios from 'axios' +import { createORM } from 'pinia-orm' +import { createPiniaOrmPluginAxios } from '@pinia-orm/axios' + +const piniaOrm = createORM() +piniaOrm().use(createPiniaOrmPluginAxios({ + axios, + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + baseURL: 'https://example.com/api/' +})) +``` + +### Model Configuration + +Options can be defined on models by setting the static `config.axiosApi` property. This is an object where you may configure model-level request configuration. + +The following example configures a model with common `headers` and `baseURL` options: + +```js +import { Model } from 'pinia-orm' + +class User extends Model { + static entity = 'users' + + static fields () { + return { + id: this.attr(null), + name: this.attr('') + } + } + + static config = { + axiosApi: { + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + baseURL: 'https://example.com/api/' + } + } +} +``` + +### Request Configuration + +Options can be defined on a per-request basis. These options will inherit any global and model configurations which are subsequently passed on to the request. + +The following example configures a request with common `headers` and `baseURL` options: + +```js +useAxiosRepo(User).api().get('/api/users', { + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + baseURL: 'https://example.com/api/' +}) +``` + +Request configurations vary depending on the type of request being made. Please refer to the [Usage Guide](usage) to read more. + + +## Available Options + +In addition to [axios request options](https://github.com/axios/axios#request-config), the plugin can be configured with the following options: + +### `dataKey` + +- **Type**: `string` +- **Default**: `undefined` + + This option will inform the plugin of the resource key your elements may be nested under in the response body. + + For example, your response body may be nested under a resource key called `data`: + + ```js + { + ok: true, + data: { + id: 1, + name: 'John Doe' + } + } + ``` + + The following example sets the `dataKey` to `data` as this is the resource key which contains the required data for the model schema. + + ```js + useAxiosRepo(User).api().get('/api/users', { + dataKey: 'data' + }) + ``` + + The plugin will pass all the data within the data object to Pinia ORM which can then be successfully persisted to the store. + + ::alert{type=warning} + This option is ignored when using the `dataTransformer` option. + :: + +### `dataTransformer` + +- **Type**: `(response: AxiosResponse) => Array | Object` +- **Default**: `undefined` + + This option will let you intercept and transform the response before persisting it to the store. + + The method will receive a [Response](usage.md#handling-responses) object allowing you to access response properties such as response headers, as well as manipulate the data as you see fit. + + Any method defined must return data to pass on to Pinia ORM. + + You can also use object destructuring to get specific properties from the response object. + + ```js + useAxiosRepo(User).api().get('/api/users', { + dataTransformer: ({ data, headers }) => { + // Do stuff with headers... + // Do stuff with data... + + return data + } + }) + ``` + + ::: warning + Using the `dataTransformer` option will ignore any `dataKey` option. + ::: + + **See also**: [Transforming Data](usage.md#transforming-data) + +### `persistBy` + +- **Type**: `string` +- **Default**: `'save'` + + This option determines which Pinia ORM persist method should be called when Pinia ORM Axios attempts to save the response data to the store. + + You can set this option to any one of the following string values: + + - `insert` + +### `save` + +- **Type**: `boolean` +- **Default**: `true` + + This option will determine whether Pinia ORM should persist the response data to the store or not. + + By setting this option to `false`, the response data will not be persisted and you will have to handle persistence alternatively. The `entities` property in the [Response](usage.md#handling-responses) object will also be `null` since it will no longer be persisting data automatically. + + **See also**: [Deferring Persistence](usage.md#deferring-persistence) + +### `delete` + +- **Type**: `string | number | (model: Model) => boolean` +- **Default**: `undefined` + + This option is primarily used with delete requests. It's argument signature is identical to the [Pinia ORM delete](https://vuex-orm.org/guide/data/deleting) method by which a primary key can be set as the value, or passing a predicate function. The corresponding record will be removed from the store after the request is made. + + Setting this option will ignore any `save` options you may have set and therefore persistence is not possible when using this option. + + **See also**: [Delete Requests](usage.md#delete-requests) + +### `actions` + +- **Type**: `Object` +- **Default**: `{}` + + This option allows for your own predefined api methods. + + Please refer to the [Custom Actions](custom-actions) documentation to learn more. diff --git a/docs/content/3.plugins/2.axios/1.guide/2.usage.md b/docs/content/3.plugins/2.axios/1.guide/2.usage.md new file mode 100644 index 000000000..a796880af --- /dev/null +++ b/docs/content/3.plugins/2.axios/1.guide/2.usage.md @@ -0,0 +1,222 @@ +# Usage + +Pinia ORM Axios adds a new repository composable `useAxiosRepo` with an asynchronous method `api()`, when called, instantiates a new axios request for a repository. From these requests, repositories are able to persist data to the store automatically. + +For example, a `useAxiosRepo(User)` model may typically want to fetch all users and persist the response to the store. Pinia ORM Axios can achieve this by performing a simple request: + +```js +await useAxiosRepo(User).api().get('https://example.com/api/users') +``` + +## Performing Requests + +Pinia ORM Axios supports the most commonly used [axios request methods](https://github.com/axios/axios#request-method-aliases). These methods accept the same argument signature as their axios counterparts with the exception that the config can be expanded with additional plugin [options](configurations). + +### Supported Methods + +Here is a list of supported request methods: + +```js +useAxiosRepo(User).api().get(url, config) +useAxiosRepo(User).api().post(url, data, config) +useAxiosRepo(User).api().put(url, data, config) +useAxiosRepo(User).api().patch(url, data, config) +useAxiosRepo(User).api().delete(url, config) +useAxiosRepo(User).api().request(config) +``` + +Arguments given are passed on to the corresponding axios request method. + +- `url` is the server URL that will be used for the request. +- `data` is the data to be sent as the request body (where applicable). +- `config` is the plugin [config options](configurations) and also any valid [axios request config](https://github.com/axios/axios#request-config) options. + +### Request Configuration + +You can pass any of the plugin's options together with any axios request options for a request method. + +For example, let's configure the following `get` request: + +```js +useAxiosRepo(User).api().get('/api/users', { + baseURL: 'https://example.com/', + dataKey: 'result' +}) +``` + +The [`baseURL`](https://github.com/axios/axios#request-config) is an axios request option which will be prepended to the request URL (unless the URL is absolute). + +The [`dataKey`](configurations.md#datakey) is a plugin option which informs the plugin of the resource key your elements may be nested under in the response body. + +> Please refer to the list of [supported request methods](#supported-methods) above to determine where the `config` argument can be given in the corresponding request method. + +**See also**: [Configurations](configurations) + +### Persisting Response Data + +By default, the response data from a request is automatically saved to the store corresponding to the model the request is made on. + +For example, let's perform a basic `get` request on a `useAxiosRepo(User)` model: + +```js +useAxiosRepo(User).api().get('https://example.com/api/users') +``` + +The response body of the request may look like the following: + +```json +[ + { + "id": 1, + "name": "John Doe", + "age": 24 + }, + { + "id": 2, + "name": "Jane Doe", + "age": 21 + } +] +``` + +Pinia ORM Axios will automatically save this data to the store, and the users entity in the store may now look like the following: + +```js +{ + users: { + data: { + 1: { id: 1, name: 'John Doe', age: 24 }, + 2: { id: 2, name: 'Jane Doe', age: 21 } + } + } +} +``` + +Under the hood, the plugin will persist data to the store by determining which records require inserting and which require updating. + +If you do not want to persist response data automatically, you can defer persistence by configuring the request with the `{ save: false }` option. + +You may configure Pinia ORM Axios to persist data using an alternative Pinia ORM persist method other than the default `save`. For example, you can use `insert`: + +```js +useAxiosRepo(User).api().get('/api/users', { persistBy: 'insert' }) +``` + +**See also**: + +- [Deferring Persistence](#deferring-persistence) +- [Pinia ORM - Inserting & Updating](https://vuex-orm.org/guide/data/inserting-and-updating.html#insert-or-update) + +### Delete Requests + +::: warning +When performing a `delete` request, the plugin will not remove the corresponding entities from the store. It is not always possible to determine which record is to be deleted and often HTTP DELETE requests are performed on a resource URL. +::: + +If you want to delete a record from the store after performing a delete request, you must pass the `delete` option. + +```js +useAxiosRepo(User).api().delete('/api/users/1', { + delete: 1 +}) +``` + +**See also**: [Configurations - delete](configurations.md#delete) + +## Handling Responses + +Every request performed will return a `Response` object as the resolved value. This object is responsible for carrying and handling the response body and ultimately executing actions such as persisting data to the store. + +The `Response` object contains two noteworthy properties: + +- `entities` is the list of entities persisted to the store by Pinia ORM. +- `response` is the original [axios response schema](https://github.com/axios/axios#response-schema). + +You may access these properties through the returned value: + +```js +const result = await useAxiosRepo(User).api().get('/api/users') + +// Retrieving the response status. +result.response.status // 200 + +// Entities persisted to the store from the response body. +result.entities // { users: [{ ... }] } +``` + +**See also**: [API Reference - Response](../api/response.md) + +### Transforming Data + +You can configure the plugin to perform transformation on the response data, using the `dataTransformer` configuration option, before it is persisted to the store. + +For example, your API response may conform to the [JSON:API](https://jsonapi.org/) specification but may not match the schema for your `useAxiosRepo(User)` model. In such cases you may want to reformat the response data in a manner in which Pinia ORM can normalize. + +The `dataTransformer` method can also be used to hook into response data before it is persisted to the store, allowing you to access other response properties such as response headers, as well as manipulate the data as you see fit. + +To demonstrate how you may use this option, let's assume your response body looks like this: + +```js +{ + ok: true, + record: { + id: 1, + name: 'John Doe' + } +} +``` + +The following example intercepts the response using a `dataTransformer` method to extract the data to be persisted from the nested property. + +```js +useAxiosRepo(User).api().get('/api/users', { + dataTransformer: (response) => { + return response.data.record + } +}) +``` + +**See also**: [Configurations - dataTransformer](configurations.md#datatransformer) + +### Deferring Persistence + +By default, the response data from a request is automatically saved to the store but this may not always be desired. + +To prevent persisting data to the store, define and set the `save` option to `false`. The `Response` object conveniently provides `save()` method which allows you to persist the data at any time. + +For example, you might want to check if the response contains any errors: + +```js +// Prevent persisting response data to the store. +const result = await useAxiosRepo(User).api().get('/api/users', { + save: false +}) + +// Throw an error. +if (result.response.data.error) { + throw new Error('Something is wrong.') +} + +// Otherwise continue to persist to the store. +result.save() +``` + +When deferring persistence you can also determine whether the response data has been persisted to the store using the convenient `isSaved` property: + +```js +const result = await useAxiosRepo(User).api().get('/api/users', { + save: false +}) + +result.isSaved // false + +await result.save() + +result.isSaved // true +``` + +**See also**: + +- [Configurations - save](configurations.md#save) +- [API Reference - Response - save()](../api/response.md#save) +- [API Reference - Response - isSaved](../api/response.md#issaved) diff --git a/docs/content/3.plugins/2.axios/1.guide/3.custom-actions.md b/docs/content/3.plugins/2.axios/1.guide/3.custom-actions.md new file mode 100644 index 000000000..b6b462219 --- /dev/null +++ b/docs/content/3.plugins/2.axios/1.guide/3.custom-actions.md @@ -0,0 +1,93 @@ +# Custom Actions + +The Custom Actions lets you define your own predefined api methods. You can define any number of custom actions through your Model configurations through `actions` option. + +```js +class User extends Model { + static entity = 'users' + + static fields () { + return { + id: this.attr(null), + name: this.attr('') + } + } + + static config = { + axiosApi: { + actions: { + fetch: { + method: 'get', + url: '/api/users' + } + } + } + } +} +``` + +You can see that in the above example, we have defined `fetch` action with the option that defines the `method` and `url`. + +Now you may call this action as if it was a predefined method. + +```js +useAxiosRepo(User).api().fetch() +``` + +The above method will perform api call to `/api/users` with `GET` method. Now the value for the action (in the example, which is the object that defines `method` and `url`) is the request configuration. Now you see that the above example is equivalent to calling: + +```js +useAxiosRepo(User).api().request({ + method: 'get', + url: '/api/users' +}) +``` + +Actions can also be defined as a function. In this case, just call the desired method with in action. With this approach, you can configure the convenience argument to the action, and gives you more powerful control. + +Remember that inside the function, `this` is bind to the Request object, not the Model where the actions are defined. + +```js +class User extends Model { + static config = { + axiosApi: { + actions: { + fetchById (id) { + return this.get(`/api/users/${id}`) + } + } + } + } +} +``` + +And you can call that action like so. + +```js +useAxiosRepo(User).api().fetchById(1) +``` + +## When to Use Custom Actions? + +While the custom actions are convenient and easy to set up, you can always define methods to the Repository directly to get pretty much the same result. But +if you don't have defined any custom `UserRepository` when its easier to use the model configuration. + +```js +class UserRepository extends AxiosRepository { + static fetchById (id) { + return this.api().get(`/api/users/${id}`) + } +} +``` + +In this case, you must call the method from Repository and not from `api()`. + +```js +useAxiosRepo(User).fetchById(1) +``` + +To be honest, this is a much better way to define custom methods in terms of simplicity and also better with type safety when using TypeScript. + +The benefits of defining custom actions inside the configuration are that you can put those methods under Request object, so it becomes more consistent when calling it from the Model. Also, it could be easier to share custom actions between different Models. + +It's up to you how to define custom actions. Though if you have any ideas or feedback, we're more than happy to hear it from you! diff --git a/docs/content/3.plugins/2.axios/2.api/1.repository.md b/docs/content/3.plugins/2.axios/2.api/1.repository.md new file mode 100644 index 000000000..8ea72a0b3 --- /dev/null +++ b/docs/content/3.plugins/2.axios/2.api/1.repository.md @@ -0,0 +1,52 @@ +--- +sidebarDepth: 2 +--- + +# Repository + +Pinia ORM Axios adds supporting properties and methods to the `Model` object. + +## Static Properties + +### `axios` + +- **Type**: `AxiosInstance | null` + + The axios instance which was either set during plugin installation or set using the [`setAxios`](#setaxios) method. Pinia ORM Axios will use this axios instance to perform requests. + +### `apiConfig` + +- **Type**: `Object` +- **Default**: `{}` + + The property that holds the model configuration for requests. + +### `globalApiConfig` + +- **Type**: `Object` + + The property that holds the global configuration. The value will be set automatically during the plugin installation process. + +::: warning WARNING +Do not mutate this property programmatically. +::: + +## Static Methods + +### `api` + +- `api(): Request` + + Return a newly created [Request](request) instance. + +### `setAxios` + +- `setAxios(axios: AxiosInstance): void` + + Set the axios instance manually. Typical setups will configure the axios instance during installation. However, in some cases (mostly with Nuxt), you may need to set the axios instance at a later stage. + + ::: warning IMPORTANT + If you omit the axios instance during installation, it's important that one is set using `setAxios` before any attempt to make an API request. + ::: + + **See also**: [Nuxt.js Integration](../guide/setup.md#nuxt-js-integration) diff --git a/docs/content/3.plugins/2.axios/2.api/2.request.md b/docs/content/3.plugins/2.axios/2.api/2.request.md new file mode 100644 index 000000000..05f38341f --- /dev/null +++ b/docs/content/3.plugins/2.axios/2.api/2.request.md @@ -0,0 +1,85 @@ +--- +sidebarDepth: 2 +--- + +# Request + +The Request object is returned when calling the `api()` method on a repository. This object is the foundation for Pinia ORM Axios and enables you to call many of the supported axios methods to perform an API request. Any [Custom Actions](../guide/custom-actions) will also be defined on the Request object. + +```js +const request = useAxiosRepo(User).api() +``` + +You can call request methods directly through chaining. + +```js +const response = useAxiosRepo(User).api().get() +``` + +## Constructor + +- `constructor(model: typeof Model)` + + By default, calling the `api()` method on a model will attach the model class to the Request object. + + You may also create a Request instance by passing a model as the constructors only param. + + ```js + import { Request } from '@pinia-orm/axios' + + const request = new Request(useAxiosRepo(User)) + ``` + +## Instance Properties + +### `model` + +- **Type**: `typeof Model` + + The model class that is attached to the Request instance. + +### `axios` + +- **Type**: `AxiosInstance` + + The axios instance that will be used to perform the request. + +## Instance Methods + +### `get` + +- `get(url: string, config: Config = {}): Promise` + + Performs a `GET` request. It takes the same arguments as the axios `get` method. + +### `post` + +- `post(url: string, data: any = {}, config: Config = {}): Promise` + + Performs a `POST` request. It takes the same arguments as the axios `post` method. + +### `put` + +- `put(url: string, data: any = {}, config: Config = {}): Promise` + + Performs a `PUT` request. It takes the same arguments as the axios `put` method. + +### `patch` + +- `patch(url: string, data: any = {}, config: Config = {}): Promise` + + Performs a `PATCH` request. It takes the same arguments as the axios `patch` method. + +### `delete` + +- `delete(url: string, config: Config = {}): Promise` + + Performs a `DELETE` request. It takes the same arguments as the axios `delete` method. + +### `request` + +- `request(config: Config): Promise` + + Performs a request with the given config options. Requests will default to `GET` if the `method` option is not specified. + + All request aliases call this method by merging the relevant configs. You may use this method if you are more familiar with using the axios API in favour of alias methods. diff --git a/docs/content/3.plugins/2.axios/2.api/3.response.md b/docs/content/3.plugins/2.axios/2.api/3.response.md new file mode 100644 index 000000000..68d2f275c --- /dev/null +++ b/docs/content/3.plugins/2.axios/2.api/3.response.md @@ -0,0 +1,59 @@ +--- +sidebarDepth: 2 +--- + +# Response + +API requests return a Response object. This is responsible for carrying and handling the response body and ultimately executing actions such as persisting data to the store. + +## Instance Properties + +### `response` + +- **Type**: `Object` + + The axios response schema. Please refer to the [axios documentation](https://github.com/axios/axios#response-schema) for more details. + +### `entities` + +- **Type**: `Array | null` + + The return value from the Pinia ORM persist method. + + **See also**: [Configurations - save](../guide/configurations.md#save) + +### `isSaved` + +- **Type**: `boolean` + + Set to `true` when response data has persisted to the store. + +### `model` + +- **Type**: `typeof Model` + + The model class that initially made the request. + +### `config` + +- **Type**: `Object` + + The configuration which was passed to the [Request](request) instance. + +## Instance Methods + +### `save` + +- `save(): Promise` + + Save response data to the store. + + **See also**: [Deferring Persistence](../guide/usage.md#deferring-persistence) + +### `delete` + +- `delete(): Promise` + + Delete record from the store after a request has completed. This method relies on the `delete` option and will throw an error if it is not set. + + **See also**: [Delete Requests](../guide/usage.md#delete-requests) diff --git a/docs/content/3.plugins/_dir.yml b/docs/content/3.plugins/_dir.yml new file mode 100644 index 000000000..0ebe984cb --- /dev/null +++ b/docs/content/3.plugins/_dir.yml @@ -0,0 +1 @@ +icon: heroicons-outline:bookmark-alt diff --git a/docs/content/3.changelog.md b/docs/content/4.changelog.md similarity index 100% rename from docs/content/3.changelog.md rename to docs/content/4.changelog.md diff --git a/packages/axios/.eslintrc b/packages/axios/.eslintrc new file mode 100644 index 000000000..0323daac5 --- /dev/null +++ b/packages/axios/.eslintrc @@ -0,0 +1,17 @@ +{ + "extends": [ + "@nuxtjs/eslint-config-typescript" + ], + "rules": { + "brace-style": "off", + "no-useless-constructor": "off", + "no-use-before-define": "off", + "no-self-compare": "off", + "func-call-spacing": "off", + "valid-typeof": "off", + "no-console": [ + "warn", + { "allow": ["clear", "info", "error", "dir", "trace", "time", "timeEnd", "warn"] } + ] + } +} diff --git a/packages/axios/.gitignore b/packages/axios/.gitignore new file mode 100644 index 000000000..562fb249e --- /dev/null +++ b/packages/axios/.gitignore @@ -0,0 +1,12 @@ +.cache +.DS_Store +.idea +*.log +*.tgz +coverage +dist +lib-cov +logs +node_modules +temp +.idea diff --git a/packages/axios/LICENSE b/packages/axios/LICENSE new file mode 100644 index 000000000..206cdb527 --- /dev/null +++ b/packages/axios/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022-present Gregor Becker + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/axios/README.md b/packages/axios/README.md new file mode 100644 index 000000000..b8d78e3ae --- /dev/null +++ b/packages/axios/README.md @@ -0,0 +1,40 @@ +[![Pinia ORM banner](./.github/assets/banner.png)](https://github.com/storm-tail/pinia-orm) + +# @pinia-orm/axios + +[![npm version][npm-version-src]][npm-version-href] +[![npm downloads][npm-downloads-src]][npm-downloads-href] +[![Github Actions CI][github-actions-ci-src]][github-actions-ci-href] +[![License][license-src]][license-href] + +> Pinia ORM Axios Plugin based on [Vuex ORM Axios](https://github.com/vuex-orm/axios) + +- [✨  Release Notes](https://pinia-orm.codedredd.de/changelog) +- [📖  Documentation](https://pinia-orm.codedredd.de) + +## Help me keep working on this project 💚 + +- [Become a Sponsor on GitHub](https://github.com/sponsors/codedredd) +- [One-time donation via PayPal](https://paypal.me/dredd1984) +- [👾  Playground](https://pinia-orm-play.codedredd.de) + +

+ + + +

+ +## License + +Made with ❤️ + +[MIT](http://opensource.org/licenses/MIT) + +[npm-version-src]: https://img.shields.io/npm/v/@pinia-orm/axios/latest.svg +[npm-version-href]: https://npmjs.com/package/@pinia-orm/axios +[npm-downloads-src]: https://img.shields.io/npm/dm/@pinia-orm/axios.svg +[npm-downloads-href]: https://npmjs.com/package/@pinia-orm/axios +[github-actions-ci-src]: https://github.com/codedredd/pinia-orm/actions/workflows/build.yml/badge.svg +[github-actions-ci-href]: https://github.com/codedredd/pinia-orm/actions?query=workflow%3Abuild +[license-src]: https://img.shields.io/npm/l/@pinia-orm/axios.svg +[license-href]: https://npmjs.com/package/@pinia-orm/axios diff --git a/packages/axios/build.config.ts b/packages/axios/build.config.ts new file mode 100644 index 000000000..1a5c71d1d --- /dev/null +++ b/packages/axios/build.config.ts @@ -0,0 +1,13 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + entries: [ + 'src/index' + ], + declaration: true, + clean: true, + rollup: { + emitCJS: true + }, + externals: ['axios', 'pinia-orm'] +}) diff --git a/packages/axios/package.json b/packages/axios/package.json new file mode 100644 index 000000000..df887fedd --- /dev/null +++ b/packages/axios/package.json @@ -0,0 +1,81 @@ +{ + "name": "@pinia-orm/axios", + "version": "1.6.7", + "description": "Axios plugin for pinia-orm", + "bugs": { + "url": "https://github.com/CodeDredd/pinia-orm/issues" + }, + "homepage": "https://github.com/CodeDredd/pinia-orm", + "repository": { + "url": "https://github.com/CodeDredd/pinia-orm.git", + "type": "git" + }, + "keywords": [ + "axios", + "pinia-orm", + "api" + ], + "files": [ + "dist/", + "index.d.ts", + "LICENSE", + "README.md" + ], + "type": "module", + "funding": "https://github.com/sponsors/codedredd", + "jsdelivr": "dist/index.mjs", + "unpkg": "dist/index.mjs", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.cjs", + "import": "./dist/index.mjs" + }, + "./*": "./*" + }, + "sideEffects": false, + "scripts": { + "build": "unbuild", + "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s --commit-path . -l @pinia-orm/axios -r 1", + "size": "size-limit", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --fix --ext .ts", + "test:ui": "vue-demi-switch 3 && vitest --ui --api 9527", + "test:watch": "vue-demi-switch 3 && vitest --watch", + "test:2": "vue-demi-switch 2 vue2 && vitest --run", + "test:3": "vue-demi-switch 3 && vitest --run", + "test": "pnpm run test:3" + }, + "author": { + "name": "Gregor Becker", + "email": "gregor@codedredd.de" + }, + "license": "MIT", + "peerDependencies": { + "pinia-orm": ">=1.6.7", + "axios": ">=1.5.0" + }, + "devDependencies": { + "@nuxtjs/eslint-config-typescript": "^12.1.0", + "@size-limit/preset-small-lib": "^8.2.6", + "@vitest/coverage-v8": "^0.34.3", + "axios": "^1.5.0", + "axios-mock-adapter": "^1.21.5", + "eslint": "^8.48.0", + "pinia-orm": "workspace:*", + "pinia": "^2.1.6", + "size-limit": "^8.2.6", + "typescript": "^5.2.2", + "unbuild": "^2.0.0", + "vitest": "^0.34.3", + "vue-demi": "^0.14.6" + }, + "size-limit": [ + { + "path": "dist/index.mjs", + "limit": "2 kB" + } + ] +} diff --git a/packages/axios/src/api/Request.ts b/packages/axios/src/api/Request.ts new file mode 100644 index 000000000..e3f96d8be --- /dev/null +++ b/packages/axios/src/api/Request.ts @@ -0,0 +1,160 @@ +import type { AxiosInstance, AxiosResponse } from 'axios' +import { Config } from '../types/config' +import { AxiosRepository } from '../repository/AxiosRepository' +import { Response } from './Response' + +export class Request { + /** + * The repository class. + */ + repository: AxiosRepository + + /** + * The default config. + */ + config: Config = { + save: true + } + + /** + * Create a new api instance. + */ + constructor (repository: AxiosRepository) { + this.repository = repository + + this.registerActions() + } + + /** + * Index key for the user defined actions. + */ + [action: string]: any + + /** + * Get the axios client. + */ + get axios (): AxiosInstance { + if (!this.repository.axios) { + throw new Error( + '[Pinia ORM Axios] The axios instance is not registered. Please register the axios instance to the repository.' + ) + } + + return this.repository.axios + } + + /** + * Register actions from the repository config. + */ + private registerActions (): void { + const actions = { ...this.repository.config.axiosApi?.actions, ...this.repository.getModel().$config()?.axiosApi?.actions } + + if (!actions) { + return + } + + for (const name in actions) { + const action = actions[name] + + typeof action === 'function' + ? this.registerFunctionAction(name, action) + : this.registerObjectAction(name, action) + } + } + + /** + * Register the given object action. + */ + private registerObjectAction (name: string, action: any): void { + this[name] = (config: Config) => { + return this.request({ ...action, ...config }) + } + } + + /** + * Register the given function action. + */ + private registerFunctionAction (name: string, action: any): void { + this[name] = action.bind(this) + } + + /** + * Perform a get request. + */ + get (url: string, config: Config = {}): Promise { + return this.request({ method: 'get', url, ...config }) + } + + /** + * Perform a post request. + */ + post (url: string, data: any = {}, config: Config = {}): Promise { + return this.request({ method: 'post', url, data, ...config }) + } + + /** + * Perform a put request. + */ + put (url: string, data: any = {}, config: Config = {}): Promise { + return this.request({ method: 'put', url, data, ...config }) + } + + /** + * Perform a patch request. + */ + patch (url: string, data: any = {}, config: Config = {}): Promise { + return this.request({ method: 'patch', url, data, ...config }) + } + + /** + * Perform a delete request. + */ + delete (url: string, config: Config = {}): Promise { + return this.request({ method: 'delete', url, ...config }) + } + + /** + * Perform an api request. + */ + async request (config: Config): Promise { + const requestConfig = this.createConfig(config) + + const axiosResponse = await this.axios.request(requestConfig) + + return this.createResponse(axiosResponse, requestConfig) + } + + /** + * Create a new config by merging the global config, the repository config, + * and the given config. + */ + private createConfig (config: Config): Config { + return { + ...this.config, + ...this.repository.globalApiConfig, + ...this.repository.apiConfig, + ...config + } + } + + /** + * Create a new response instance by applying a few initialization processes. + * For example, it saves response data if `save` option id set to `true`. + */ + private async createResponse ( + axiosResponse: AxiosResponse, + config: Config + ): Promise { + const response = new Response(this.repository, config, axiosResponse) + + if (config.delete !== undefined) { + await response.delete() + + return response + } + + config.save && (await response.save()) + + return response + } +} diff --git a/packages/axios/src/api/Response.ts b/packages/axios/src/api/Response.ts new file mode 100644 index 000000000..a12eac1ff --- /dev/null +++ b/packages/axios/src/api/Response.ts @@ -0,0 +1,140 @@ +import type { AxiosResponse } from 'axios' +import { Element, Collection } from 'pinia-orm' +import { Config, PersistMethods } from '../types/config' +import { AxiosRepository } from '../repository/AxiosRepository' + +export class Response { + /** + * The repository that called the request. + */ + repository: AxiosRepository + + /** + * The request configuration. + */ + config: Config + + /** + * The axios response instance. + */ + response: AxiosResponse + + /** + * Entities created by Pinia ORM. + */ + entities: Collection | null = null + + /** + * Whether if response data is saved to the store or not. + */ + isSaved: boolean = false + + /** + * Create a new response instance. + */ + constructor (repository: AxiosRepository, config: Config, response: AxiosResponse) { + this.repository = repository + this.config = config + this.response = response + } + + /** + * Save response data to the store. + */ + async save (): Promise { + const data = this.getDataFromResponse() + + if (!this.validateData(data)) { + console.warn( + '[Pinia ORM Axios] The response data could not be saved to the store ' + + 'because it is not an object or an array. You might want to use ' + + '`dataTransformer` option to handle non-array/object response ' + + 'before saving it to the store.' + ) + + return + } + + let method: PersistMethods = this.config.persistBy || 'save' + + if (!this.validatePersistAction(method)) { + console.warn( + '[Pinia ORM Axios] The "persistBy" option configured is not a ' + + 'recognized value. Response data will be persisted by the ' + + 'default `save` method.' + ) + + method = 'save' + } + + const result = await this.repository[method](data) + + this.entities = Array.isArray(result) ? result : [result] + + this.isSaved = true + } + + /** + * Delete the entity record where the `delete` option is configured. + */ + async delete (): Promise { + if (this.config.delete === undefined) { + throw new Error( + '[Pinia ORM Axios] Could not delete records because the `delete` option is not set.' + ) + } + + await this.repository.query().destroy(this.config.delete as any) + } + + /** + * Get the response data from the axios response object. If a `dataTransformer` + * option is configured, it will be applied to the response object. If the + * `dataKey` option is configured, it will return the data from the given + * property within the response body. + */ + getDataFromResponse (): Element | Element[] { + if (this.config.dataTransformer) { + return this.config.dataTransformer(this.response) + } + + if (this.config.dataKey) { + return this.response.data[this.config.dataKey] + } + + return this.response.data + } + + /** + * Get persist options if any set in config. + */ + // protected getPersistOptions (): PersistOptions | undefined { + // const persistOptions = this.config.persistOptions + // + // if (!persistOptions || typeof persistOptions !== 'object') { + // return + // } + // + // return Object.keys(persistOptions) + // .filter(this.validatePersistAction) // Filter to avoid polluting the payload. + // .reduce((carry, key) => { + // carry[key] = persistOptions[key] + // return carry + // }, {} as PersistOptions) + // } + + /** + * Validate the given data to ensure the Pinia ORM persist methods accept it. + */ + protected validateData (data: any): data is Element | Element[] { + return data !== null && typeof data === 'object' + } + + /** + * Validate the given string as to ensure it correlates with the available + * Pinia ORM persist methods. + */ + protected validatePersistAction (action: string): action is PersistMethods { + return ['save', 'insert'].includes(action) + } +} diff --git a/packages/axios/src/composables/useAxiosApi.ts b/packages/axios/src/composables/useAxiosApi.ts new file mode 100644 index 000000000..5ace17d3a --- /dev/null +++ b/packages/axios/src/composables/useAxiosApi.ts @@ -0,0 +1,6 @@ +import { Request } from '../api/Request' +import { AxiosRepository } from '../repository/AxiosRepository' + +export function useAxiosApi (repository: AxiosRepository) { + return new Request(repository) +} diff --git a/packages/axios/src/composables/useAxiosRepo.ts b/packages/axios/src/composables/useAxiosRepo.ts new file mode 100644 index 000000000..11372dc05 --- /dev/null +++ b/packages/axios/src/composables/useAxiosRepo.ts @@ -0,0 +1,7 @@ +import { useRepo, Model } from 'pinia-orm' +import { AxiosRepository } from '../repository/AxiosRepository' + +export function useAxiosRepo (model: M) { + AxiosRepository.useModel = model + return useRepo(AxiosRepository) +} diff --git a/packages/axios/src/index.ts b/packages/axios/src/index.ts new file mode 100644 index 000000000..d269a9017 --- /dev/null +++ b/packages/axios/src/index.ts @@ -0,0 +1,6 @@ +export * from './api/Response' +export * from './api/Request' +export * from './repository/AxiosRepository' +export * from './composables/useAxiosApi' +export * from './composables/useAxiosRepo' +export * from './plugin' diff --git a/packages/axios/src/plugin.ts b/packages/axios/src/plugin.ts new file mode 100644 index 000000000..aef529358 --- /dev/null +++ b/packages/axios/src/plugin.ts @@ -0,0 +1,11 @@ +import { PiniaOrmPlugin, definePiniaOrmPlugin } from 'pinia-orm' +import { GlobalConfig } from './types/config' + +export function createPiniaOrmAxios (axiosConfig?: GlobalConfig): PiniaOrmPlugin { + return definePiniaOrmPlugin((context) => { + context.config.axiosApi = axiosConfig + return context + }) +} + +export const piniaOrmPluginAxios = createPiniaOrmAxios() diff --git a/packages/axios/src/repository/AxiosRepository.ts b/packages/axios/src/repository/AxiosRepository.ts new file mode 100644 index 000000000..10ff6e4dd --- /dev/null +++ b/packages/axios/src/repository/AxiosRepository.ts @@ -0,0 +1,19 @@ +import { Repository, Model, config } from 'pinia-orm' +import type { AxiosInstance } from 'axios' +import { useAxiosApi } from '../index' +import { Config } from '../types/config' + +export class AxiosRepository extends Repository { + axios: AxiosInstance = config?.axiosApi?.axios || null + globalApiConfig = config?.axiosApi || {} + apiConfig: Config = {} + + api () { + return useAxiosApi(this) + } + + setAxios (axios: AxiosInstance) { + this.axios = axios + return this + } +} diff --git a/packages/axios/src/types/config.ts b/packages/axios/src/types/config.ts new file mode 100644 index 000000000..01a445db6 --- /dev/null +++ b/packages/axios/src/types/config.ts @@ -0,0 +1,29 @@ +import { Element, Model } from 'pinia-orm' +import type { AxiosRequestConfig, AxiosInstance, AxiosResponse } from 'axios' + +export type PersistMethods = 'save' | 'insert' + +export type PersistOptions = { [P in PersistMethods]?: string[] } + +export interface Config extends AxiosRequestConfig { + dataKey?: string + url?: string + method?: string + data?: any + dataTransformer?: (response: AxiosResponse) => Element | Element[] + save?: boolean + persistBy?: PersistMethods + persistOptions?: PersistOptions + delete?: string | number | ((model: Model) => boolean) + actions?: { + [name: string]: any + } +} + +export interface GlobalConfig extends Config { + axios?: AxiosInstance +} + +export interface InstallConfig { + axiosApi?: GlobalConfig +} diff --git a/packages/axios/src/types/pinia-orm.ts b/packages/axios/src/types/pinia-orm.ts new file mode 100644 index 000000000..b044d4428 --- /dev/null +++ b/packages/axios/src/types/pinia-orm.ts @@ -0,0 +1,8 @@ +import type { InstallOptions as IOptions, FilledInstallOptions as FOptions, ModelConfigOptions as MCOptions } from 'pinia-orm' +import { Config, InstallConfig } from './config' + +declare module 'pinia-orm' { + export type InstallOptions = IOptions & InstallConfig + export type FilledInstallOptions = FOptions & Required + export type ModelConfigOptions = MCOptions & { axios: Config } +} diff --git a/packages/axios/test/feature/Request.spec.ts b/packages/axios/test/feature/Request.spec.ts new file mode 100644 index 000000000..f99ae2e81 --- /dev/null +++ b/packages/axios/test/feature/Request.spec.ts @@ -0,0 +1,235 @@ +import axios from 'axios' +import MockAdapter from 'axios-mock-adapter' +import { Model } from 'pinia-orm' +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { assertState } from '../helpers' +import { useAxiosRepo } from '../../src' + +describe('Feature - Request', () => { + let mock: MockAdapter + + class User extends Model { + static entity = 'users' + + static fields () { + return { + id: this.attr(null), + name: this.attr('') + } + } + } + + beforeEach(() => { + mock = new MockAdapter(axios) + }) + afterEach(() => { + mock.reset() + }) + + it('`get` can perform a get request', async () => { + mock.onGet('/api/users').reply(200, [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Doe' } + ]) + + const userStore = useAxiosRepo(User) + + await userStore.api().get('/api/users') + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' }, + 2: { id: 2, name: 'Jane Doe' } + } + }) + }) + + it('`get` can perform a get request with additional config', async () => { + mock.onGet('/api/users').reply(200, { + data: [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Doe' } + ] + }) + + const userStore = useAxiosRepo(User) + + await userStore.api().get('/api/users', { dataKey: 'data' }) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' }, + 2: { id: 2, name: 'Jane Doe' } + } + }) + }) + + it('`post` can perform a post request', async () => { + mock.onPost('/api/users').reply(200, [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Doe' } + ]) + + const userStore = useAxiosRepo(User) + + await userStore.api().post('/api/users') + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' }, + 2: { id: 2, name: 'Jane Doe' } + } + }) + }) + + it('`post` can perform a post request with additional config', async () => { + mock.onPost('/api/users').reply(200, { + data: [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Doe' } + ] + }) + + const userStore = useAxiosRepo(User) + + await userStore.api().post('/api/users', {}, { dataKey: 'data' }) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' }, + 2: { id: 2, name: 'Jane Doe' } + } + }) + }) + + it('`put` can perform a put request', async () => { + mock.onPut('/api/users').reply(200, [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Doe' } + ]) + + const userStore = useAxiosRepo(User) + + await userStore.api().put('/api/users') + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' }, + 2: { id: 2, name: 'Jane Doe' } + } + }) + }) + + it('`put` can perform a put request with additional config', async () => { + mock.onPut('/api/users').reply(200, { + data: [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Doe' } + ] + }) + + const userStore = useAxiosRepo(User) + + await userStore.api().put('/api/users', {}, { dataKey: 'data' }) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' }, + 2: { id: 2, name: 'Jane Doe' } + } + }) + }) + + it('`patch` can perform a patch request', async () => { + mock.onPatch('/api/users').reply(200, [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Doe' } + ]) + + const userStore = useAxiosRepo(User) + + await userStore.api().patch('/api/users') + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' }, + 2: { id: 2, name: 'Jane Doe' } + } + }) + }) + + it('`patch` can perform a patch request with additional config', async () => { + mock.onPatch('/api/users').reply(200, { + data: [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Doe' } + ] + }) + + const userStore = useAxiosRepo(User) + + await userStore.api().patch('/api/users', {}, { dataKey: 'data' }) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' }, + 2: { id: 2, name: 'Jane Doe' } + } + }) + }) + + it('`delete` can perform a delete request', async () => { + mock.onDelete('/api/users').reply(200, [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Doe' } + ]) + + const userStore = useAxiosRepo(User) + + await userStore.api().delete('/api/users') + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' }, + 2: { id: 2, name: 'Jane Doe' } + } + }) + }) + + it('`delete` can perform a delete request with additional config', async () => { + mock.onDelete('/api/users').reply(200, { + data: [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Doe' } + ] + }) + + const userStore = useAxiosRepo(User) + + await userStore.api().delete('/api/users', { dataKey: 'data' }) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' }, + 2: { id: 2, name: 'Jane Doe' } + } + }) + }) + + it('throws error if `axios` is not set', () => { + const userStore = useAxiosRepo(User).setAxios(null) + + try { + const axios = userStore.api().axios + console.warn(axios) + } catch (e) { + expect(e.message).toBe( + '[Pinia ORM Axios] The axios instance is not registered. Please register the axios instance to the repository.' + ) + + return + } + + throw new Error('Error was not thrown') + }) +}) diff --git a/packages/axios/test/feature/Request_Actions.spec.ts b/packages/axios/test/feature/Request_Actions.spec.ts new file mode 100644 index 000000000..09fc7d625 --- /dev/null +++ b/packages/axios/test/feature/Request_Actions.spec.ts @@ -0,0 +1,86 @@ +import axios from 'axios' +import MockAdapter from 'axios-mock-adapter' +import { Model } from 'pinia-orm' +import { describe, it, beforeEach, afterEach } from 'vitest' +import { assertState } from '../helpers' +import { useAxiosRepo } from '../../src' +import type { Request, Response } from '../../src' + +describe('Feature - Request - Actions', () => { + let mock: MockAdapter + + beforeEach(() => { + mock = new MockAdapter(axios) + }) + afterEach(() => { + mock.reset() + }) + + it('can define a custom action', async () => { + class User extends Model { + static entity = 'users' + + static fields () { + return { + id: this.attr(null), + name: this.attr('') + } + } + + static config = { + axiosApi: { + actions: { + fetch: { method: 'get', url: '/users' } + } + } + } + } + + mock.onGet('/users').reply(200, { id: 1, name: 'John' }) + + const userStore = useAxiosRepo(User) + + await userStore.api().fetch() + + assertState({ + users: { + 1: { id: 1, name: 'John' } + } + }) + }) + + it('can define a custom action as a function', async () => { + class User extends Model { + static entity = 'users' + + static fields () { + return { + id: this.attr(null), + name: this.attr('') + } + } + + static config = { + axiosApi: { + actions: { + fetch (this: Request, url: string): Promise { + return this.get(url) + } + } + } + } + } + + mock.onGet('/users').reply(200, { id: 1, name: 'John' }) + + const userStore = useAxiosRepo(User) + + await userStore.api().fetch('/users') + + assertState({ + users: { + 1: { id: 1, name: 'John' } + } + }) + }) +}) diff --git a/packages/axios/test/feature/Request_DataKey.spec.ts b/packages/axios/test/feature/Request_DataKey.spec.ts new file mode 100644 index 000000000..7fc6a6134 --- /dev/null +++ b/packages/axios/test/feature/Request_DataKey.spec.ts @@ -0,0 +1,47 @@ +import axios from 'axios' +import MockAdapter from 'axios-mock-adapter' +import { Model } from 'pinia-orm' +import { describe, it, beforeEach, afterEach } from 'vitest' +import { assertState } from '../helpers' +import { useAxiosRepo } from '../../src' + +describe('Feature - Request - Data Key', () => { + let mock: MockAdapter + + class User extends Model { + static entity = 'users' + + static fields () { + return { + id: this.attr(null), + name: this.attr('') + } + } + } + + beforeEach(() => { + mock = new MockAdapter(axios) + }) + afterEach(() => { + mock.reset() + }) + + it('can specify which resource key to extract data from', async () => { + mock.onGet('/users').reply(200, { + data: { id: 1, name: 'John Doe' } + }) + + const userStore = useAxiosRepo(User) + + await userStore.api().request({ + url: '/users', + dataKey: 'data' + }) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' } + } + }) + }) +}) diff --git a/packages/axios/test/feature/Request_DataTransformer.spec.ts b/packages/axios/test/feature/Request_DataTransformer.spec.ts new file mode 100644 index 000000000..6c15039b6 --- /dev/null +++ b/packages/axios/test/feature/Request_DataTransformer.spec.ts @@ -0,0 +1,47 @@ +import axios from 'axios' +import MockAdapter from 'axios-mock-adapter' +import { Model } from 'pinia-orm' +import { describe, it, beforeEach, afterEach } from 'vitest' +import { assertState } from '../helpers' +import { useAxiosRepo } from '../../src' + +describe('Feature - Request - Data Transformer', () => { + let mock: MockAdapter + + class User extends Model { + static entity = 'users' + + static fields () { + return { + id: this.attr(null), + name: this.attr('') + } + } + } + + beforeEach(() => { + mock = new MockAdapter(axios) + }) + afterEach(() => { + mock.reset() + }) + + it('can specify a callback to transform the response', async () => { + mock.onGet('/users').reply(200, { + data: { id: 1, name: 'John Doe' } + }) + + const userStore = useAxiosRepo(User) + + await userStore.api().request({ + url: '/users', + dataTransformer: ({ data }) => data.data + }) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' } + } + }) + }) +}) diff --git a/packages/axios/test/feature/Request_Delete.spec.ts b/packages/axios/test/feature/Request_Delete.spec.ts new file mode 100644 index 000000000..fc51615c5 --- /dev/null +++ b/packages/axios/test/feature/Request_Delete.spec.ts @@ -0,0 +1,51 @@ +import axios from 'axios' +import MockAdapter from 'axios-mock-adapter' +import { Model } from 'pinia-orm' +import { describe, it, beforeEach, afterEach } from 'vitest' +import { assertState } from '../helpers' +import { useAxiosRepo } from '../../src' + +describe('Feature - Request - Delete', () => { + let mock: MockAdapter + + class User extends Model { + static entity = 'users' + + static fields () { + return { + id: this.attr(null), + name: this.attr('') + } + } + } + + beforeEach(() => { + mock = new MockAdapter(axios) + }) + afterEach(() => { + mock.reset() + }) + + it('can delete a record after the api call', async () => { + mock.onDelete('/users/1').reply(200, { ok: true }) + + const userStore = useAxiosRepo(User) + + userStore.save([ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' } + ]) + + await userStore.api().request({ + method: 'delete', + url: '/users/1', + delete: 1 + }) + + assertState({ + users: { + 2: { id: 2, name: 'Jane' } + } + }) + }) +}) diff --git a/packages/axios/test/feature/Request_Save.spec.ts b/packages/axios/test/feature/Request_Save.spec.ts new file mode 100644 index 000000000..59aef8456 --- /dev/null +++ b/packages/axios/test/feature/Request_Save.spec.ts @@ -0,0 +1,45 @@ +import axios from 'axios' +import MockAdapter from 'axios-mock-adapter' +import { Model } from 'pinia-orm' +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { assertState } from '../helpers' +import { useAxiosRepo } from '../../src' + +describe('Feature - Request - Save', () => { + let mock: MockAdapter + + class User extends Model { + static entity = 'users' + + static fields () { + return { + id: this.attr(null), + name: this.attr('') + } + } + } + + beforeEach(() => { + mock = new MockAdapter(axios) + }) + afterEach(() => { + mock.reset() + }) + + it('can prevent persisting response data to the store', async () => { + mock.onGet('/users').reply(200, { + data: { id: 1, name: 'John Doe' } + }) + + const userStore = useAxiosRepo(User) + + const result = await userStore.api().request({ + url: '/users', + save: false + }) + + expect(result.entities).toBe(null) + + assertState({}) + }) +}) diff --git a/packages/axios/test/feature/Response_Delete.spec.ts b/packages/axios/test/feature/Response_Delete.spec.ts new file mode 100644 index 000000000..b1cdbe019 --- /dev/null +++ b/packages/axios/test/feature/Response_Delete.spec.ts @@ -0,0 +1,68 @@ +import axios from 'axios' +import MockAdapter from 'axios-mock-adapter' +import { Model } from 'pinia-orm' +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { assertState } from '../helpers' +import { useAxiosRepo } from '../../src' + +describe('Feature - Response - Save', () => { + let mock: MockAdapter + + class User extends Model { + static entity = 'users' + + static fields () { + return { + id: this.attr(null), + name: this.attr('') + } + } + } + + beforeEach(() => { + mock = new MockAdapter(axios) + }) + afterEach(() => { + mock.reset() + }) + + it('can save response data manually', async () => { + mock.onGet('/api/users').reply(200, { id: 1, name: 'John Doe' }) + + const userStore = useAxiosRepo(User) + + const response = await userStore.api().get('/api/users') + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' } + } + }) + + response.config.delete = 1 + + await response.delete() + + assertState({ users: {} }) + }) + + it('throws error if `delete` option is not set', async () => { + mock.onGet('/api/users').reply(200, { id: 1, name: 'John Doe' }) + + const userStore = useAxiosRepo(User) + + const response = await userStore.api().get('/api/users') + + try { + await response.delete() + } catch (e) { + expect(e.message).toBe( + '[Pinia ORM Axios] Could not delete records because the `delete` option is not set.' + ) + + return + } + + throw new Error('Error was not thrown') + }) +}) diff --git a/packages/axios/test/feature/Response_Save.spec.ts b/packages/axios/test/feature/Response_Save.spec.ts new file mode 100644 index 000000000..b861c24a6 --- /dev/null +++ b/packages/axios/test/feature/Response_Save.spec.ts @@ -0,0 +1,79 @@ +import axios from 'axios' +import MockAdapter from 'axios-mock-adapter' +import { Model } from 'pinia-orm' +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { assertState } from '../helpers' +import { useAxiosRepo } from '../../src' + +describe('Feature - Response - Save', () => { + let mock: MockAdapter + + class User extends Model { + static entity = 'users' + + static fields () { + return { + id: this.attr(null), + name: this.attr('') + } + } + } + + beforeEach(() => { + mock = new MockAdapter(axios) + }) + afterEach(() => { + mock.reset() + }) + + it('warns the user if the response data cannot be inserted', async () => { + const spy = vi.spyOn(console, 'warn') + + spy.mockImplementation(x => x) + + const userStore = useAxiosRepo(User) + + mock.onGet('/api/users').reply(200, null) + await userStore.api().get('/api/users') + + mock.onGet('/api/users').reply(200, 1) + await userStore.api().get('/api/users') + + expect(console.warn).toHaveBeenCalledTimes(2) + + spy.mockReset() + spy.mockRestore() + }) + + it('can save response data manually', async () => { + mock.onGet('/api/users').reply(200, { id: 1, name: 'John Doe' }) + + const userStore = useAxiosRepo(User) + + const response = await userStore.api().get('/api/users', { save: false }) + + assertState({}) + + await response.save() + + assertState({ + users: { + 1: { id: 1, name: 'John Doe' } + } + }) + }) + + it('sets `isSaved` flag', async () => { + mock.onGet('/api/users').reply(200, { id: 1, name: 'John Doe' }) + + const userStore = useAxiosRepo(User) + + const response = await userStore.api().get('/api/users', { save: false }) + + expect(response.isSaved).toBe(false) + + await response.save() + + expect(response.isSaved).toBe(true) + }) +}) diff --git a/packages/axios/test/helpers.ts b/packages/axios/test/helpers.ts new file mode 100644 index 000000000..509461211 --- /dev/null +++ b/packages/axios/test/helpers.ts @@ -0,0 +1,28 @@ +import { getActivePinia } from 'pinia' +import { expect } from 'vitest' +import type { Elements } from 'pinia-orm' + +interface Entities { + [name: string]: Elements +} + +export function createState (entities: Entities, additionalStoreProperties = {}): any { + const state = {} as any + + for (const entity in entities) { + if (!state[entity]) { state[entity] = { data: {}, ...additionalStoreProperties } } + + state[entity].data = entities[entity] + } + + return state +} + +export function fillState (entities: Entities): void { + // @ts-expect-error + getActivePinia().state.value = createState(entities) +} + +export function assertState (entities: Entities, additionalStoreProperties?: Record): void { + expect(getActivePinia()?.state.value).toEqual(createState(entities, additionalStoreProperties)) +} diff --git a/packages/axios/test/setup.ts b/packages/axios/test/setup.ts new file mode 100644 index 000000000..77e64cd7b --- /dev/null +++ b/packages/axios/test/setup.ts @@ -0,0 +1,28 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeAll, beforeEach } from 'vitest' +import { Vue2, createApp, install, isVue2 } from 'vue-demi' +import { Model, createORM, useRepo } from 'pinia-orm' +import axios from 'axios' +import { createPiniaOrmAxios } from '../src' + +beforeAll(() => { + if (isVue2) { + Vue2.config.productionTip = false + Vue2.config.devtools = false + install(Vue2) + } +}) + +beforeEach(() => { + const app = createApp({}) + const pinia = createPinia() + const piniaOrm = createORM() + piniaOrm().use(createPiniaOrmAxios({ + axios + })) + pinia.use(piniaOrm) + app.use(pinia) + setActivePinia(pinia) + Model.clearBootedModels() + useRepo(Model).hydratedDataCache.clear() +}) diff --git a/packages/axios/tsconfig.json b/packages/axios/tsconfig.json new file mode 100644 index 000000000..0d9b23ea7 --- /dev/null +++ b/packages/axios/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "jsx": "preserve", + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Node", + "skipLibCheck": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "strict": true, + "allowJs": true, + "noEmit": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/packages/axios/vitest.config.ts b/packages/axios/vitest.config.ts new file mode 100644 index 000000000..91895939d --- /dev/null +++ b/packages/axios/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + setupFiles: ['./test/setup.ts'], + externals: ['pinia-orm'], + coverage: { + enabled: true, + reporter: ['lcov', 'text', 'html'] + } + } +}) diff --git a/packages/pinia-orm/package.json b/packages/pinia-orm/package.json index b160ff1b9..3cbbe47b3 100644 --- a/packages/pinia-orm/package.json +++ b/packages/pinia-orm/package.json @@ -97,7 +97,7 @@ "size-limit": [ { "path": "dist/index.mjs", - "limit": "12 kB" + "limit": "13 kB" }, { "path": "dist/decorators.mjs", diff --git a/packages/pinia-orm/src/composables/useRepo.ts b/packages/pinia-orm/src/composables/useRepo.ts index 63187aefe..23b2fa4a6 100644 --- a/packages/pinia-orm/src/composables/useRepo.ts +++ b/packages/pinia-orm/src/composables/useRepo.ts @@ -3,9 +3,10 @@ import type { Model } from '../model/Model' import { Repository } from '../repository/Repository' import { Database } from '../database/Database' import type { Constructor } from '../types' +import { registerPlugins } from '../store/Plugins' export function useRepo( - repository: R, + repository: R | Constructor, pinia?: Pinia, ): R @@ -33,5 +34,5 @@ export function useRepo ( } } catch (e) {} - return repository + return registerPlugins(repository) } diff --git a/packages/pinia-orm/src/index.ts b/packages/pinia-orm/src/index.ts index 2e107653e..568d49df3 100644 --- a/packages/pinia-orm/src/index.ts +++ b/packages/pinia-orm/src/index.ts @@ -4,6 +4,7 @@ export * from './composables/useStoreActions' export * from './composables/useDataStore' export * from './composables/mapRepos' export * from './store/Config' +export * from './store/Plugins' export * from './store/Store' export * from './database/Database' export * from './schema/Schema' diff --git a/packages/pinia-orm/src/model/Model.ts b/packages/pinia-orm/src/model/Model.ts index 71c284305..1ce74bfad 100644 --- a/packages/pinia-orm/src/model/Model.ts +++ b/packages/pinia-orm/src/model/Model.ts @@ -101,7 +101,7 @@ export class Model { /** * The global install options */ - static config: ModelConfigOptions + static config: ModelConfigOptions & { [key: string]: any } /** * The type key for the model. @@ -603,7 +603,7 @@ export class Model { /** * Get the model config. */ - $config (): ModelConfigOptions { + $config (): ModelConfigOptions & { [key: string]: any } { return this.$self().config } diff --git a/packages/pinia-orm/src/repository/Repository.ts b/packages/pinia-orm/src/repository/Repository.ts index 3a44fe72a..5b541427c 100644 --- a/packages/pinia-orm/src/repository/Repository.ts +++ b/packages/pinia-orm/src/repository/Repository.ts @@ -20,9 +20,11 @@ import { useDataStore } from '../composables/useDataStore' import { cache } from '../cache/SharedWeakCache' import { cache as hydratedDataCache } from '../cache/SharedHydratedDatakCache' import type { WeakCache } from '../cache/WeakCache' -import { config } from '../store/Config' +import { config as globalConfig } from '../store/Config' +import { FilledInstallOptions } from '../store/Store' export class Repository { + [index: string]: any /** * A special flag to indicate if this is the repository class or not. It's * used when retrieving repository instance from `store.$repo()` method to @@ -60,22 +62,40 @@ export class Repository { */ use?: typeof Model + /** + * The model object to be used for the custom repository. + */ + static useModel?: Model + + /** + * Global config + */ + config: FilledInstallOptions & { [key: string]: any } + /** * Create a new Repository instance. */ constructor (database: Database, pinia?: Pinia) { + this.config = globalConfig this.database = database this.pinia = pinia this.hydratedDataCache = hydratedDataCache as Map } + /** + * Set the global config + */ + setConfig (config: FilledInstallOptions) { + this.config = config + } + /** * Initialize the repository by setting the model instance. */ initialize (model?: ModelConstructor): this { - if (config.cache && config.cache !== true) { + if (this.config.cache && this.config.cache !== true) { // eslint-disable-next-line new-cap - this.queryCache = (config.cache.shared ? cache : new config.cache.provider()) as WeakCache + this.queryCache = (this.config.cache.shared ? cache : new this.config.cache.provider()) as WeakCache } // If there's a model passed in, just use that and return immediately. @@ -88,7 +108,8 @@ export class Repository { // passed repository to the `store.$repo` method instead of a model. // In this case, we'll check if the user has set model to the `use` // property and instantiate that. - if (this.use) { + if (this.use || this.$self().useModel) { + this.use = (this.use ?? this.$self().useModel) as typeof Model this.model = this.use.newRawInstance() as M return this } @@ -98,6 +119,13 @@ export class Repository { return this } + /** + * Get the constructor for this model. + */ + $self (): typeof Repository { + return this.constructor as typeof Repository + } + /** * Get the model instance. If the model is not registered to the repository, * it will throw an error. It happens when users use a custom repository diff --git a/packages/pinia-orm/src/schema/Schema.ts b/packages/pinia-orm/src/schema/Schema.ts index de4799ac2..9dc2bd5d7 100644 --- a/packages/pinia-orm/src/schema/Schema.ts +++ b/packages/pinia-orm/src/schema/Schema.ts @@ -113,7 +113,6 @@ export class Schema { // If the `key` is not `null`, that means this record is a nested // relationship of the parent model. In this case, we'll attach any // missing foreign keys to the record first. - console.log('schema', parent.$fields(), key, model.$entity(), parent.$entity()) if (key !== null) { (parent.$fields()[key] as Relation)?.attach(parentRecord, record) } // Next, we'll generate any missing primary key fields defined as diff --git a/packages/pinia-orm/src/store/Config.ts b/packages/pinia-orm/src/store/Config.ts index 0f6aab730..d1bfe1bc7 100644 --- a/packages/pinia-orm/src/store/Config.ts +++ b/packages/pinia-orm/src/store/Config.ts @@ -14,4 +14,4 @@ export const CONFIG_DEFAULTS = { } } -export const config: FilledInstallOptions = { ...CONFIG_DEFAULTS } +export const config: FilledInstallOptions & { [key: string]: any } = { ...CONFIG_DEFAULTS } diff --git a/packages/pinia-orm/src/store/Plugins.ts b/packages/pinia-orm/src/store/Plugins.ts new file mode 100644 index 000000000..1a3b45f5c --- /dev/null +++ b/packages/pinia-orm/src/store/Plugins.ts @@ -0,0 +1,28 @@ +import { FilledInstallOptions, Model, Repository } from '../../src' +import { config as globalConfig } from './Config' + +export interface PiniaOrmPluginContext { + model: Model + repository: Repository + config: FilledInstallOptions & { [key: string]: any } +} + +export interface PiniaOrmPlugin { + (context: PiniaOrmPluginContext): PiniaOrmPluginContext +} + +export const definePiniaOrmPlugin = (plugin: PiniaOrmPlugin) => plugin + +export const plugins: PiniaOrmPlugin[] = [] + +export function registerPlugins (repository: Repository) { + let config = globalConfig + plugins.forEach((plugin) => { + const pluginConfig = plugin({ config, repository, model: repository.getModel() }) + config = { ...config, ...pluginConfig.config } + }) + + repository.setConfig(config) + + return repository +} diff --git a/packages/pinia-orm/src/store/Store.ts b/packages/pinia-orm/src/store/Store.ts index 4d9b8819c..31ec3d279 100644 --- a/packages/pinia-orm/src/store/Store.ts +++ b/packages/pinia-orm/src/store/Store.ts @@ -1,7 +1,7 @@ -import type { PiniaPlugin } from 'pinia' import type { WeakCache } from '../cache/WeakCache' import type { Model } from '../model/Model' import { CONFIG_DEFAULTS, config } from './Config' +import { PiniaOrmPlugin, plugins } from './Plugins' export interface ModelConfigOptions { withMeta?: boolean @@ -25,11 +25,21 @@ export interface FilledInstallOptions { cache: Required } +export interface CreatePiniaOrm { + use(plugin: PiniaOrmPlugin): this +} + /** * Install Pinia ORM to the store. */ -export function createORM (options?: InstallOptions): PiniaPlugin { +export function createORM (options?: InstallOptions) { config.model = { ...CONFIG_DEFAULTS.model, ...options?.model } config.cache = options?.cache === false ? false : { ...CONFIG_DEFAULTS.cache, ...(options?.cache !== true && options?.cache) } - return () => {} + const orm = { + use (plugin: PiniaOrmPlugin) { + plugins.push(plugin) + return this + } + } + return () => orm } diff --git a/packages/pinia-orm/src/support/Utils.ts b/packages/pinia-orm/src/support/Utils.ts index 0c343b007..7f7a3dd43 100644 --- a/packages/pinia-orm/src/support/Utils.ts +++ b/packages/pinia-orm/src/support/Utils.ts @@ -1,4 +1,4 @@ -import type { Element } from '@' +import type { Element } from '../../src' interface SortableArray { criteria: any[] diff --git a/packages/pinia-orm/tests/feature/plugin/plugin.test.ts b/packages/pinia-orm/tests/feature/plugin/plugin.test.ts new file mode 100644 index 000000000..e2cf6e40d --- /dev/null +++ b/packages/pinia-orm/tests/feature/plugin/plugin.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { Model, useRepo, definePiniaOrmPlugin } from '../../../src' +import { Attr, Str } from '../../../src/decorators' +import { createPiniaORM } from '../../helpers' + +describe('feature/plugin/plugin', () => { + class User extends Model { + static entity = 'users' + + @Attr(0) declare id: number + @Str('') declare name: string + @Str('') declare username: string + } + + it('can add extra config to the configuration', () => { + const plugin = definePiniaOrmPlugin((context) => { + context.config.apiConfig = 'test' + return context + }) + + createPiniaORM(undefined, [plugin]) + + const userRepo = useRepo(User) + + expect(userRepo.config.apiConfig).toBe('test') + }) + + it('can extend repository', () => { + const plugin = definePiniaOrmPlugin((context) => { + context.repository.test = () => { return 'test' } + return context + }) + + createPiniaORM(undefined, [plugin]) + + const userRepo = useRepo(User) + + expect(userRepo.test()).toBe('test') + }) +}) diff --git a/packages/pinia-orm/tests/feature/relations/eager_loads/eager_loads_all.spec.ts b/packages/pinia-orm/tests/feature/relations/eager_loads/eager_loads_all.spec.ts index 1d777354a..f6f4ad0da 100644 --- a/packages/pinia-orm/tests/feature/relations/eager_loads/eager_loads_all.spec.ts +++ b/packages/pinia-orm/tests/feature/relations/eager_loads/eager_loads_all.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Model, useRepo } from '../../../../src' import { Attr, BelongsTo, HasMany, Str } from '../../../../src/decorators' -import { assertInstanceOf, assertModel } from '../../../helpers' +import { assertInstanceOf, assertModel, assertModels } from '../../../helpers' describe('feature/relations/eager_loads_all', () => { class User extends Model { @@ -57,4 +57,40 @@ describe('feature/relations/eager_loads_all', () => { comments: [{ id: 1, postId: 1, content: 'Content 01' }] }) }) + + it('eager loads without cached relations', () => { + const usersRepo = useRepo(User) + const postsRepo = useRepo(Post) + const commentsRepo = useRepo(Comment) + + usersRepo.save({ id: 1, name: 'John Doe' }) + + postsRepo.save({ id: 1, userId: 1, title: 'Title 01' }) + + commentsRepo.save({ id: 1, postId: 1, content: 'Content 01' }) + + const onlyPosts = postsRepo.all() + const posts = postsRepo.withAllRecursive().get() + const onlyPosts2 = postsRepo.all() + + assertModels(posts, [{ + id: 1, + userId: 1, + title: 'Title 01', + author: { id: 1, name: 'John Doe' }, + comments: [{ id: 1, postId: 1, content: 'Content 01' }] + }]) + + assertModels(onlyPosts, [{ + id: 1, + userId: 1, + title: 'Title 01' + }]) + + assertModels(onlyPosts2, [{ + id: 1, + userId: 1, + title: 'Title 01' + }]) + }) }) diff --git a/packages/pinia-orm/tests/helpers.ts b/packages/pinia-orm/tests/helpers.ts index a5065bf69..2a4f7759e 100644 --- a/packages/pinia-orm/tests/helpers.ts +++ b/packages/pinia-orm/tests/helpers.ts @@ -7,7 +7,7 @@ import type { Mock } from 'vitest' import { expect, vi } from 'vitest' import { createApp } from 'vue-demi' -import type { Collection, Elements, InstallOptions, Model } from '../src' +import type { Collection, Elements, InstallOptions, Model, PiniaOrmPlugin } from '../src' import * as Utils from '../src/support/Utils' import { createORM } from '../src' @@ -15,10 +15,14 @@ interface Entities { [name: string]: Elements } -export function createPiniaORM (options?: InstallOptions) { +export function createPiniaORM (options?: InstallOptions, plugins?: PiniaOrmPlugin[]) { const app = createApp({}) const pinia = createPinia() - pinia.use(createORM(options)) + const piniaOrm = createORM(options) + if (plugins) { + plugins.forEach(plugin => piniaOrm().use(plugin)) + } + pinia.use(piniaOrm) app.use(pinia) setActivePinia(pinia) } diff --git a/packages/pinia-orm/tests/unit/PiniaORM.spec.ts b/packages/pinia-orm/tests/unit/PiniaORM.spec.ts index ffee3bce4..0bfc24c17 100644 --- a/packages/pinia-orm/tests/unit/PiniaORM.spec.ts +++ b/packages/pinia-orm/tests/unit/PiniaORM.spec.ts @@ -124,7 +124,7 @@ describe('unit/PiniaORM', () => { @Str('') declare name: string @Str('') declare username: string } - createPiniaORM({ model: { nameSpace: 'orm' } }) + createPiniaORM({ model: { namespace: 'orm' } }) const userRepo = useRepo(User) const user = userRepo.save({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cd3bbda9..5e6d71d89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,48 @@ importers: specifier: ^2.0.0 version: 2.0.0 + packages/axios: + devDependencies: + '@nuxtjs/eslint-config-typescript': + specifier: ^12.1.0 + version: 12.1.0(eslint@8.48.0)(typescript@5.2.2) + '@size-limit/preset-small-lib': + specifier: ^8.2.6 + version: 8.2.6(size-limit@8.2.6) + '@vitest/coverage-v8': + specifier: ^0.34.3 + version: 0.34.3(vitest@0.34.3) + axios: + specifier: ^1.5.0 + version: 1.5.0 + axios-mock-adapter: + specifier: ^1.21.5 + version: 1.21.5(axios@1.5.0) + eslint: + specifier: ^8.48.0 + version: 8.48.0 + pinia: + specifier: ^2.1.6 + version: 2.1.6(@vue/composition-api@1.7.2)(typescript@5.2.2)(vue@3.3.4) + pinia-orm: + specifier: workspace:* + version: link:../pinia-orm + size-limit: + specifier: ^8.2.6 + version: 8.2.6 + typescript: + specifier: ^5.2.2 + version: 5.2.2 + unbuild: + specifier: ^2.0.0 + version: 2.0.0(typescript@5.2.2) + vitest: + specifier: ^0.34.3 + version: 0.34.3(@vitest/ui@0.34.3)(happy-dom@10.11.2) + vue-demi: + specifier: ^0.14.6 + version: 0.14.6(@vue/composition-api@1.7.2)(vue@3.3.4) + packages/normalizr: devDependencies: '@nuxtjs/eslint-config-typescript': @@ -2441,7 +2483,7 @@ packages: '@rollup/pluginutils': 5.0.4(rollup@3.28.1) commondir: 1.0.1 estree-walker: 2.0.2 - glob: 8.0.3 + glob: 8.1.0 is-reference: 1.2.1 magic-string: 0.27.0 rollup: 3.28.1 @@ -2542,21 +2584,6 @@ packages: picomatch: 2.3.1 dev: true - /@rollup/pluginutils@5.0.3(rollup@3.28.1): - resolution: {integrity: sha512-hfllNN4a80rwNQ9QCxhxuHCGHMAvabXqxNdaChUSSadMre7t4iEUI6fFAhBOn/eIYTgYVhBv7vCLsAJ4u3lf3g==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0 - peerDependenciesMeta: - rollup: - optional: true - dependencies: - '@types/estree': 1.0.0 - estree-walker: 2.0.2 - picomatch: 2.3.1 - rollup: 3.28.1 - dev: true - /@rollup/pluginutils@5.0.4(rollup@3.28.1): resolution: {integrity: sha512-0KJnIoRI8A+a1dqOYLxH8vBf8bphDmty5QvIm2hqm7oFCFYKCAZWWd2hXgMibaPsNDhI0AtpYfQZJG47pt/k4g==} engines: {node: '>=14.0.0'} @@ -3224,7 +3251,7 @@ packages: '@vue/reactivity-transform': 3.3.4 '@vue/shared': 3.3.4 estree-walker: 2.0.2 - magic-string: 0.30.0 + magic-string: 0.30.3 postcss: 8.4.29 source-map-js: 1.0.2 dev: true @@ -3745,6 +3772,16 @@ packages: resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} dev: true + /axios-mock-adapter@1.21.5(axios@1.5.0): + resolution: {integrity: sha512-5NI1V/VK+8+JeTF8niqOowuysA4b8mGzdlMN/QnTnoXbYh4HZSNiopsDclN2g/m85+G++IrEtUdZaQ3GnaMsSA==} + peerDependencies: + axios: '>= 0.17.0' + dependencies: + axios: 1.5.0 + fast-deep-equal: 3.1.3 + is-buffer: 2.0.5 + dev: true + /axios@0.27.2: resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} dependencies: @@ -3754,6 +3791,16 @@ packages: - debug dev: true + /axios@1.5.0: + resolution: {integrity: sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==} + dependencies: + follow-redirects: 1.15.1 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: true + /b4a@1.6.4: resolution: {integrity: sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==} dev: true @@ -4139,12 +4186,6 @@ packages: engines: {node: '>=8'} dev: true - /citty@0.1.2: - resolution: {integrity: sha512-Me9nf0/BEmMOnuQzMOVXgpzkMUNbd0Am8lTl/13p0aRGAoLGk5T5sdet/42CrIGmWdG67BgHUhcKK1my1ujUEg==} - dependencies: - consola: 3.2.3 - dev: true - /citty@0.1.3: resolution: {integrity: sha512-tb6zTEb2BDSrzFedqFYFUKUuKNaxVJWCm7o02K4kADGkBDyyiz7D40rDMpguczdZyAN3aetd5fhpB01HkreNyg==} dependencies: @@ -5390,11 +5431,11 @@ packages: debug: 4.3.4 enhanced-resolve: 5.15.0 eslint: 8.48.0 - eslint-module-utils: 2.7.4(@typescript-eslint/parser@6.5.0)(eslint-import-resolver-typescript@3.6.0)(eslint@8.48.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.5.0)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.0)(eslint@8.48.0) eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.5.0)(eslint-import-resolver-typescript@3.6.0)(eslint@8.48.0) fast-glob: 3.3.1 get-tsconfig: 4.7.0 - is-core-module: 2.12.1 + is-core-module: 2.13.0 is-glob: 4.0.3 transitivePeerDependencies: - '@typescript-eslint/parser' @@ -5403,35 +5444,6 @@ packages: - supports-color dev: true - /eslint-module-utils@2.7.4(@typescript-eslint/parser@6.5.0)(eslint-import-resolver-typescript@3.6.0)(eslint@8.48.0): - resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - dependencies: - '@typescript-eslint/parser': 6.5.0(eslint@8.48.0)(typescript@5.2.2) - debug: 3.2.7 - eslint: 8.48.0 - eslint-import-resolver-typescript: 3.6.0(@typescript-eslint/parser@6.5.0)(eslint-plugin-import@2.28.1)(eslint@8.48.0) - transitivePeerDependencies: - - supports-color - dev: true - /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.5.0)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.0)(eslint@8.48.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} @@ -6407,17 +6419,6 @@ packages: path-is-absolute: 1.0.1 dev: true - /glob@8.0.3: - resolution: {integrity: sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==} - engines: {node: '>=12'} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 5.1.6 - once: 1.4.0 - dev: true - /glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} @@ -6989,6 +6990,11 @@ packages: has-tostringtag: 1.0.0 dev: true + /is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + dev: true + /is-builtin-module@3.2.1: resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} engines: {node: '>=6'} @@ -7798,13 +7804,6 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true - /magic-string@0.30.0: - resolution: {integrity: sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==} - engines: {node: '>=12'} - dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 - dev: true - /magic-string@0.30.3: resolution: {integrity: sha512-B7xGbll2fG/VjP+SWg4sX3JynwIU0mjoTc6MPpKNuIvftk6u6vqhDnk1R80b8C2GBR6ywqy+1DcKBrevBg+bmw==} engines: {node: '>=12'} @@ -8141,15 +8140,6 @@ packages: typescript: 5.2.2 dev: true - /mlly@1.4.0: - resolution: {integrity: sha512-ua8PAThnTwpprIaU47EPeZ/bPUVp2QYBbWMphUQpVdBI3Lgqzm5KZQ45Agm3YJedHXaIHl6pBGabaLSUPPSptg==} - dependencies: - acorn: 8.10.0 - pathe: 1.1.1 - pkg-types: 1.0.3 - ufo: 1.3.0 - dev: true - /mlly@1.4.1: resolution: {integrity: sha512-SCDs78Q2o09jiZiE2WziwVBEqXQ02XkGdUy45cbJf+BpYRIjArXRJ1Wbowxkb+NaM9DWvS3UC9GiO/6eqvQ/pg==} dependencies: @@ -9222,7 +9212,7 @@ packages: resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==} dependencies: jsonc-parser: 3.2.0 - mlly: 1.4.1 + mlly: 1.4.2 pathe: 1.1.1 /pluralize@8.0.0: @@ -9669,6 +9659,10 @@ packages: resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==} dev: true + /proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + dev: true + /prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} @@ -9948,7 +9942,7 @@ packages: resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} hasBin: true dependencies: - is-core-module: 2.12.1 + is-core-module: 2.13.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 dev: true @@ -11220,9 +11214,9 @@ packages: '@rollup/plugin-json': 6.0.0(rollup@3.28.1) '@rollup/plugin-node-resolve': 15.2.1(rollup@3.28.1) '@rollup/plugin-replace': 5.0.2(rollup@3.28.1) - '@rollup/pluginutils': 5.0.3(rollup@3.28.1) + '@rollup/pluginutils': 5.0.4(rollup@3.28.1) chalk: 5.3.0 - citty: 0.1.2 + citty: 0.1.3 consola: 3.2.3 defu: 6.1.2 esbuild: 0.19.2 @@ -11231,7 +11225,7 @@ packages: jiti: 1.19.3 magic-string: 0.30.3 mkdist: 1.3.0(typescript@5.2.2) - mlly: 1.4.0 + mlly: 1.4.2 pathe: 1.1.1 pkg-types: 1.0.3 pretty-bytes: 6.1.1