├── package.json.cjs ├── docs ├── .vuepress │ ├── package.json │ ├── components │ │ ├── PostgrestDocs.vue │ │ └── Query.vue │ └── config.js ├── README.md ├── start │ └── index.md ├── query │ └── index.md ├── guide │ └── index.md └── api │ └── index.md ├── .browserslistrc ├── babel.config.cjs ├── .editorconfig ├── src ├── use.js ├── utils │ ├── index.js │ ├── reflect.js │ ├── split.js │ ├── aliases.js │ ├── pk.js │ ├── diff.js │ └── reactivity.js ├── index.js ├── Plugin.js ├── RPC.js ├── Route.js ├── Postgrest.js ├── errors.js ├── ObservableFunction.js ├── mixin.js ├── GenericCollection.js ├── request.js ├── Schema.js ├── GenericModel.js └── Query.js ├── tests ├── setup.js ├── unit │ ├── Errors.spec.js │ ├── utils.spec.js │ ├── Route.spec.js │ ├── RPC.spec.js │ ├── Plugin.spec.js │ ├── Component.spec.js │ ├── Schema.spec.js │ ├── GenericCollection.spec.js │ ├── ObservableFunction.spec.js │ ├── mixin.spec.js │ ├── Query.spec.js │ └── Request.spec.js └── fetch.mock.js ├── .releaserc.yaml ├── .gitignore ├── jest.config.js ├── eslint.config.js ├── vite.config.js ├── LICENSE ├── .github ├── renovate.json └── workflows │ ├── pull_request.yaml │ └── push.yaml ├── package.json └── README.md /package.json.cjs: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /docs/.vuepress/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not IE 11 4 | not dead 5 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /src/use.js: -------------------------------------------------------------------------------- 1 | import Schema from '@/Schema' 2 | 3 | function usePostgrest (...args) { 4 | return new Schema(...args) 5 | } 6 | 7 | export default usePostgrest 8 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | import { enableFetchMocks } from 'jest-fetch-mock' 2 | import postgrestMock from './fetch.mock.js' 3 | enableFetchMocks() 4 | fetch.mockResponse(postgrestMock) 5 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export * from '@/utils/aliases' 2 | export * from '@/utils/diff' 3 | export * from '@/utils/pk' 4 | export * from '@/utils/reactivity' 5 | export * from '@/utils/split' 6 | -------------------------------------------------------------------------------- /src/utils/reflect.js: -------------------------------------------------------------------------------- 1 | function reflectHelper (keys, ret, target, property, ...args) { 2 | if (keys.includes(property)) return ret 3 | return Reflect[this](target, property, ...args) 4 | } 5 | 6 | export { reflectHelper } 7 | -------------------------------------------------------------------------------- /.releaserc.yaml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - "@semantic-release/commit-analyzer" 3 | - "@semantic-release/release-notes-generator" 4 | - "@semantic-release/npm" 5 | - - "@semantic-release/github" 6 | - successCommentCondition: <% return issue.user.type !== 'Bot'; %> 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /coverage 5 | /docs/.vuepress/dist 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /docs/.vuepress/components/PostgrestDocs.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | 16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Plugin from '@/Plugin' 2 | import { resetSchemaCache, setDefaultToken } from '@/Schema' 3 | import pg from '@/mixin' 4 | import { AuthError, FetchError, PrimaryKeyError, SchemaNotFoundError } from '@/errors' 5 | import usePostgrest from '@/use' 6 | 7 | export { pg, AuthError, FetchError, PrimaryKeyError, SchemaNotFoundError, resetSchemaCache, setDefaultToken, usePostgrest, Plugin as default } 8 | -------------------------------------------------------------------------------- /src/utils/split.js: -------------------------------------------------------------------------------- 1 | // split strings of the format key1=value1,key2=value2,... into object 2 | function splitToObject (str, fieldDelimiter = ',', kvDelimiter = '=') { 3 | return str.split(fieldDelimiter).reduce((acc, field) => { 4 | const parts = field.split(kvDelimiter) 5 | acc[parts[0].trim()] = parts[1] ? parts[1].replace(/^["\s]+|["\s]+$/g, '') : undefined 6 | return acc 7 | }, {}) 8 | } 9 | 10 | export { splitToObject } 11 | -------------------------------------------------------------------------------- /src/Plugin.js: -------------------------------------------------------------------------------- 1 | import Postgrest from './Postgrest' 2 | import { setDefaultRoot } from './Schema' 3 | import { setDefaultHeaders } from './request' 4 | import usePostgrest from './use' 5 | 6 | export default { 7 | install (Vue, options = {}) { 8 | // use the mergeHook strategy for onError that is also used for vue lifecycle hooks 9 | Vue.config.optionMergeStrategies.onError = Vue.config.optionMergeStrategies.created 10 | Vue.component('postgrest', Postgrest) 11 | Object.defineProperty(Vue.prototype, '$postgrest', { 12 | get: usePostgrest 13 | }) 14 | setDefaultRoot(options.apiRoot) 15 | setDefaultHeaders(options.headers) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/aliases.js: -------------------------------------------------------------------------------- 1 | function mapAliasesFromSelect (select = [], data) { 2 | const kvPairs = 3 | Array.isArray(select) 4 | ? select.map(k => [k, true]) 5 | : typeof select === 'string' 6 | ? select.split(',').map(k => [k, true]) 7 | : Object.entries(select) 8 | const alias2column = new Map(kvPairs 9 | .map(([k, v]) => { 10 | if (!v) return false 11 | const [alias, column] = k.split(':') 12 | return [alias, column ?? alias] 13 | }) 14 | .filter(Boolean) 15 | ) 16 | return Object.fromEntries(Object.entries(data).map(([alias, value]) => [alias2column.get(alias) ?? alias, value])) 17 | } 18 | 19 | export { mapAliasesFromSelect } 20 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | base: '/vue-postgrest/', 3 | title: 'vue-postgrest', 4 | description: 'PostgREST integration for Vue.js', 5 | host: 'localhost', 6 | themeConfig: { 7 | repo: 'technowledgy/vue-postgrest', 8 | docsDir: 'docs', 9 | docsBranch: 'main', 10 | editLinks: true, 11 | smoothScroll: true, 12 | sidebarDepth: 2, 13 | sidebar: [ 14 | '/start/', 15 | '/guide/', 16 | '/api/', 17 | '/query/' 18 | ], 19 | lastUpdated: 'Last Updated', 20 | nextLinks: false, 21 | prevLinks: false 22 | }, 23 | plugins: [ 24 | '@vuepress/active-header-links', 25 | '@vuepress/back-to-top' 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | collectCoverage: true, 3 | collectCoverageFrom: [ 4 | '/src/**' 5 | ], 6 | coverageReporters: [ 7 | 'lcov', 8 | 'text-summary' 9 | ], 10 | moduleNameMapper: { 11 | '^@/(.*)$': '/src/$1' 12 | }, 13 | setupFilesAfterEnv: [ 14 | '/tests/setup.js' 15 | ], 16 | testEnvironment: 'jsdom', 17 | testEnvironmentOptions: { 18 | url: 'http://localhost/nested/path' 19 | }, 20 | testMatch: [ 21 | '**/tests/unit/**/*.spec.js' 22 | ], 23 | transform: { 24 | '^.+\\.js$': 'babel-jest' 25 | }, 26 | watchPlugins: [ 27 | 'jest-watch-typeahead/filename', 28 | 'jest-watch-typeahead/testname' 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /tests/unit/Errors.spec.js: -------------------------------------------------------------------------------- 1 | import { AuthError, FetchError } from '@/errors' 2 | 3 | describe('AuthError', () => { 4 | it('parses error and error_description from WWW-Authenticate header', () => { 5 | try { 6 | throw new AuthError({ 7 | headers: new Headers({ 8 | 'WWW-Authenticate': 'Bearer error="invalid_token", error_description="JWT expired"' 9 | }) 10 | }) 11 | } catch (e) { 12 | expect(e.error).toBe('invalid_token') 13 | expect(e.error_description).toBe('JWT expired') 14 | } 15 | }) 16 | }) 17 | 18 | describe('FetchError', () => { 19 | it('uses statusText as message', () => { 20 | try { 21 | throw new FetchError({ 22 | statusText: 'status text' 23 | }) 24 | } catch (e) { 25 | expect(e.message).toBe('status text') 26 | } 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | actionText: Get Started → 4 | actionLink: /start/ 5 | footer: MIT Licensed | Copyright © 2020 Sports Technowledgy UG 6 | --- 7 | 8 |
9 |
10 |

Flexible

11 | Make requests through <postgrest> components, the pg mixin or $postgrest instance methods. 12 |
13 |
14 |

Easy to use

15 | Edit items with v-model and persist with item.$post(), item.$patch() and item.$delete(). 16 |
17 |
18 |

Powerful

19 | Fully supports the PostgREST query syntax, including filtering, resource embedding and RPCs. 20 |
21 |
22 | 23 |
24 |
Uses the Fetch API under the hood. Dependency free.
25 |
-------------------------------------------------------------------------------- /src/utils/pk.js: -------------------------------------------------------------------------------- 1 | import { PrimaryKeyError } from '@/errors' 2 | 3 | function createPKQuery (pkColumns = [], data = {}) { 4 | try { 5 | // we can't get/put/patch/delete on a route without PK 6 | if (pkColumns.length === 0) throw new PrimaryKeyError() 7 | return pkColumns.reduce((query, col) => { 8 | if (data[col] === undefined || data[col] === null) { 9 | throw new PrimaryKeyError(col) 10 | } 11 | // TODO: do we need .is for Boolean PKs? 12 | query[col + '.eq'] = data[col] 13 | return query 14 | }, {}) 15 | } catch (e) { 16 | if (e instanceof PrimaryKeyError) { 17 | // we are returning the PrimaryKeyError here, because it will be thrown later again, 18 | // when one of the methods that need a query to succeed is called 19 | return e 20 | } else { 21 | throw e 22 | } 23 | } 24 | } 25 | 26 | export { createPKQuery } 27 | -------------------------------------------------------------------------------- /src/RPC.js: -------------------------------------------------------------------------------- 1 | class RPC extends Function { 2 | constructor (request, ready) { 3 | super('', 'return arguments.callee._call.apply(arguments.callee, arguments)') 4 | this._request = request 5 | // non-enumerable $ready prop returning the promise, just for tests 6 | Object.defineProperty(this, '$ready', { 7 | value: ready 8 | }) 9 | } 10 | 11 | async _call (fn, signal, params, opts) { 12 | if (!(signal instanceof AbortSignal)) { 13 | opts = params 14 | params = signal 15 | signal = undefined 16 | } 17 | const { get, query, ...requestOptions } = opts ?? {} 18 | if (get) { 19 | return this._request('rpc/' + fn, 'GET', Object.assign({}, query, params), { ...requestOptions, signal }) 20 | } else { 21 | return this._request('rpc/' + fn, 'POST', query, { ...requestOptions, signal }, params) 22 | } 23 | } 24 | } 25 | 26 | export default RPC 27 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc' 2 | import globals from 'globals' 3 | import parser from '@babel/eslint-parser' 4 | import vue from 'eslint-plugin-vue' 5 | 6 | const compat = new FlatCompat() 7 | 8 | export default [ 9 | { 10 | ignores: [ 11 | 'coverage/**', 12 | 'dist/**', 13 | 'docs/**', 14 | 'package.json.cjs' 15 | ] 16 | }, 17 | ...compat.extends('@vue/standard'), 18 | ...vue.configs['flat/vue2-essential'], 19 | { 20 | files: ['**/*.js', '**/*.vue'], 21 | languageOptions: { 22 | parser 23 | }, 24 | rules: { 25 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 26 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 27 | 'vue/multi-word-component-names': 'off' 28 | } 29 | }, 30 | { 31 | files: ['**/*.spec.js'], 32 | languageOptions: { 33 | globals: globals.jest 34 | } 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /src/Route.js: -------------------------------------------------------------------------------- 1 | class Route extends Function { 2 | constructor (request, ready) { 3 | super('', 'return arguments.callee.request.apply(arguments.callee, arguments)') 4 | this.request = request 5 | // TODO: check if we can wrap this in an ObservableFunction 6 | this.options = request.bind(null, 'OPTIONS') 7 | this.get = request.bind(null, 'GET') 8 | this.head = request.bind(null, 'HEAD') 9 | this.post = request.bind(null, 'POST') 10 | this.put = request.bind(null, 'PUT') 11 | this.patch = request.bind(null, 'PATCH') 12 | this.delete = request.bind(null, 'DELETE') 13 | // non-enumerable $ready prop returning the promise, just for tests 14 | Object.defineProperty(this, '$ready', { 15 | value: ready 16 | }) 17 | } 18 | 19 | _extractFromDefinition (tableDef) { 20 | this.columns = Object.keys(tableDef.properties) 21 | this.pks = Object.entries(tableDef.properties) 22 | .filter(([field, fieldDef]) => fieldDef.description?.includes('')) 23 | .map(([field]) => field) 24 | } 25 | } 26 | 27 | export default Route 28 | -------------------------------------------------------------------------------- /src/Postgrest.js: -------------------------------------------------------------------------------- 1 | import pg from '@/mixin' 2 | 3 | export default { 4 | name: 'Postgrest', 5 | mixins: [pg], 6 | props: { 7 | route: { 8 | type: String, 9 | required: true 10 | }, 11 | apiRoot: { 12 | type: String 13 | }, 14 | token: { 15 | type: String 16 | }, 17 | query: { 18 | type: Object 19 | }, 20 | single: { 21 | type: Boolean 22 | }, 23 | limit: { 24 | type: Number 25 | }, 26 | offset: { 27 | type: Number 28 | }, 29 | count: { 30 | type: String 31 | } 32 | }, 33 | computed: { 34 | pgConfig () { 35 | return { 36 | route: this.route, 37 | apiRoot: this.apiRoot, 38 | token: this.token, 39 | query: this.query, 40 | single: this.single, 41 | limit: this.limit, 42 | offset: this.offset, 43 | count: this.count 44 | } 45 | } 46 | }, 47 | onError (err) { 48 | this.$emit('error', err) 49 | }, 50 | render (h) { 51 | return this.$scopedSlots.default(this.pg) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url' 2 | import { dirname, resolve } from 'path' 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue2' 5 | import copy from 'rollup-plugin-copy' 6 | 7 | const repoDir = dirname(fileURLToPath(import.meta.url)) 8 | 9 | export default defineConfig({ 10 | build: { 11 | commonjsOptions: { include: [] }, 12 | lib: { 13 | entry: resolve(repoDir, 'src/index.js'), 14 | formats: ['es', 'cjs'] 15 | }, 16 | minify: false, 17 | rollupOptions: { 18 | external: ['vue'], 19 | output: { 20 | entryFileNames: '[format]/vue-postgrest.js', 21 | exports: 'named' 22 | } 23 | }, 24 | sourcemap: true 25 | }, 26 | plugins: [ 27 | vue(), 28 | copy({ 29 | targets: [ 30 | { src: 'package.json.cjs', dest: 'dist/cjs', rename: 'package.json' } 31 | ], 32 | hook: 'writeBundle' 33 | }) 34 | ], 35 | resolve: { 36 | alias: [ 37 | { 38 | find: /^@\//, 39 | replacement: resolve(repoDir, 'src') + '/' 40 | } 41 | ] 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sports Technowledgy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:js-lib" 4 | ], 5 | "packageRules": [ 6 | { 7 | "matchFileNames": [ 8 | "package.json" 9 | ], 10 | "matchDepTypes": [ 11 | "devDependencies" 12 | ], 13 | "matchUpdateTypes": [ 14 | "patch", 15 | "minor" 16 | ], 17 | "groupName": "devDependencies" 18 | }, 19 | { 20 | "matchPackageNames": [ 21 | "@vue/test-utils" 22 | ], 23 | "allowedVersions": "<2.0.0" 24 | }, 25 | { 26 | "matchPackageNames": [ 27 | "vue", 28 | "vue-template-compiler", 29 | "vue-server-renderer" 30 | ], 31 | "groupName": "vue", 32 | "allowedVersions": "<3.0.0" 33 | }, 34 | { 35 | "matchPackageNames": [ 36 | "lru-cache" 37 | ], 38 | "allowedVersions": "<6.0.0" 39 | }, 40 | { 41 | "matchUpdateTypes": [ 42 | "minor", 43 | "patch", 44 | "pin", 45 | "digest" 46 | ], 47 | "automerge": true 48 | } 49 | ], 50 | "postUpdateOptions": [ 51 | "yarnDedupeHighest" 52 | ], 53 | "lockFileMaintenance": { 54 | "enabled": true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docs/start/index.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | To get started, install vue-postgrest via your package manager: 4 | 5 | ``` bash 6 | yarn add vue-postgrest 7 | # OR npm install vue-postgrest 8 | ``` 9 | 10 | Import and install the Plugin in your `main.js`: 11 | 12 | ``` javascript 13 | import Vue from 'vue' 14 | import Postgrest from 'vue-postgrest' 15 | 16 | Vue.use(Postgrest) 17 | ``` 18 | 19 | You can use the `` component: 20 | 21 | ``` html 22 | 23 | 26 | 27 | ``` 28 | 29 | Use the `pg` mixin: 30 | 31 | ``` vue 32 | 35 | 36 | 54 | ``` 55 | 56 | Or you can directly use the instance method provided on your Vue instance: 57 | 58 | ``` javascript 59 | this.$postgrest.ROUTE.get() 60 | ``` 61 | 62 | For in depth documentation see the [API](../api) and [Query](../query) documentation. 63 | -------------------------------------------------------------------------------- /docs/.vuepress/components/Query.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 41 | 42 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | import { splitToObject } from '@/utils' 2 | 3 | class FetchError extends Error { 4 | constructor (resp, body) { 5 | super(resp.statusText) 6 | this.name = 'FetchError' 7 | this.resp = resp 8 | this.status = resp.status 9 | Object.assign(this, body) 10 | } 11 | } 12 | 13 | class AuthError extends FetchError { 14 | constructor (resp, body) { 15 | super(resp, body) 16 | this.name = 'AuthError' 17 | Object.assign(this, splitToObject(resp.headers.get('WWW-Authenticate').replace(/^Bearer /, ''))) 18 | } 19 | } 20 | 21 | class PrimaryKeyError extends Error { 22 | constructor (pk) { 23 | super(`Primary key not found ${pk ?? ''}`) 24 | this.name = 'PrimaryKeyError' 25 | } 26 | } 27 | 28 | class SchemaNotFoundError extends Error { 29 | constructor (apiRoot, err) { 30 | super('No openapi definition found for api-root: ' + apiRoot) 31 | this.name = SchemaNotFoundError 32 | this.causedBy = err 33 | } 34 | } 35 | 36 | async function throwWhenStatusNotOk (resp) { 37 | if (!resp.ok) { 38 | let body = {} 39 | try { 40 | body = await resp.json() 41 | } catch {} 42 | if (resp.headers.get('WWW-Authenticate')) { 43 | throw new AuthError(resp, body) 44 | } 45 | throw new FetchError(resp, body) 46 | } 47 | return resp 48 | } 49 | 50 | export { 51 | AuthError, 52 | FetchError, 53 | PrimaryKeyError, 54 | SchemaNotFoundError, 55 | throwWhenStatusNotOk 56 | } 57 | -------------------------------------------------------------------------------- /src/ObservableFunction.js: -------------------------------------------------------------------------------- 1 | import { createReactivePrototype } from '@/utils' 2 | 3 | // function that exposes some vue reactive properties about it's current running state 4 | // use like observed_fn = new ObservableFunction(orig_fn) 5 | class ObservableFunction extends Function { 6 | constructor (fn) { 7 | super() 8 | return new Proxy(createReactivePrototype(this, this), { 9 | apply: async (target, thisArg, argumentsList) => { 10 | const controller = new AbortController() 11 | this.pending.push(controller) 12 | try { 13 | const ret = await fn(controller.signal, ...argumentsList) 14 | this.clear() 15 | this.hasReturned = true 16 | return ret 17 | } catch (e) { 18 | this.errors.push(e) 19 | throw e 20 | } finally { 21 | this.pending = this.pending.filter(p => p !== controller) 22 | } 23 | } 24 | }) 25 | } 26 | 27 | hasReturned = false 28 | 29 | pending = [] 30 | 31 | get isPending () { 32 | return this.pending.length > 0 33 | } 34 | 35 | errors = [] 36 | 37 | get hasError () { 38 | return this.errors.length > 0 39 | } 40 | 41 | clear (...args) { 42 | if (args.length) { 43 | this.errors = this.errors.filter((e, i) => !args.includes(e) && !args.includes(i)) 44 | } else { 45 | this.errors = [] 46 | this.hasReturned = false 47 | } 48 | } 49 | } 50 | 51 | export default ObservableFunction 52 | -------------------------------------------------------------------------------- /tests/unit/utils.spec.js: -------------------------------------------------------------------------------- 1 | import { createPKQuery, mapAliasesFromSelect, splitToObject } from '@/utils' 2 | import { PrimaryKeyError } from '@/errors' 3 | 4 | describe('utils', () => { 5 | describe('createPKQuery', () => { 6 | it('returns PrimaryKeyError without pks', () => { 7 | expect(createPKQuery()).toBeInstanceOf(PrimaryKeyError) 8 | }) 9 | 10 | it('throws with non-string keys', () => { 11 | // non-string key is just any error that is not PrimaryKeyError 12 | // those should be thrown instead of returned 13 | expect(() => createPKQuery([Symbol('sym')])).toThrow() 14 | }) 15 | 16 | it('returns PrimaryKeyError with missing key', () => { 17 | expect(createPKQuery(['a'], { b: 1 })).toBeInstanceOf(PrimaryKeyError) 18 | }) 19 | 20 | it('returns proper pk query', () => { 21 | expect(createPKQuery(['a'], { a: 1, b: 2 })).toEqual({ 22 | 'a.eq': 1 23 | }) 24 | }) 25 | }) 26 | 27 | describe('mapAliasesFromSelect', () => { 28 | it('returns same keys with empty select', () => { 29 | expect(mapAliasesFromSelect(undefined, { a: 'a', b: 'b' })).toEqual({ a: 'a', b: 'b' }) 30 | }) 31 | }) 32 | 33 | describe('splitToObject', () => { 34 | it('splits single pair', () => { 35 | expect(splitToObject('k=v')).toEqual({ k: 'v' }) 36 | }) 37 | 38 | it('splits multiple pairs', () => { 39 | expect(splitToObject('k1=v1,k2=v2')).toEqual({ k1: 'v1', k2: 'v2' }) 40 | }) 41 | 42 | it('returns undefined for field without delimiter', () => { 43 | expect(splitToObject('k1,k2=v2')).toEqual({ k1: undefined, k2: 'v2' }) 44 | }) 45 | 46 | it('removes quotes properly', () => { 47 | expect(splitToObject('k1="test"test"')).toEqual({ k1: 'test"test' }) 48 | }) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | lint: 8 | name: Lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6.0.1 12 | - uses: actions/setup-node@v6.1.0 13 | with: 14 | cache: yarn 15 | check-latest: true 16 | node-version: 24 17 | - run: yarn 18 | - run: yarn lint --no-fix 19 | 20 | build: 21 | name: Build 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v6.0.1 25 | - uses: actions/setup-node@v6.1.0 26 | with: 27 | cache: yarn 28 | check-latest: true 29 | node-version: 24 30 | - run: yarn 31 | - run: yarn build 32 | 33 | coverage: 34 | name: Test Coverage 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v6.0.1 38 | - uses: actions/setup-node@v6.1.0 39 | with: 40 | cache: yarn 41 | check-latest: true 42 | node-version: 24 43 | - run: yarn 44 | - run: yarn test 45 | - uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6 46 | with: 47 | github-token: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | test: 50 | name: Test LTS 51 | runs-on: ubuntu-latest 52 | strategy: 53 | fail-fast: false 54 | matrix: 55 | node: 56 | - 22 57 | steps: 58 | - uses: actions/checkout@v6.0.1 59 | - uses: actions/setup-node@v6.1.0 60 | with: 61 | cache: yarn 62 | node-version: ${{ matrix.node }} 63 | - run: yarn 64 | - run: yarn test 65 | 66 | docs: 67 | name: Build Docs 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v6.0.1 71 | - uses: actions/setup-node@v6.1.0 72 | with: 73 | cache: yarn 74 | check-latest: true 75 | node-version: 24 76 | - run: yarn 77 | - run: yarn docs:build 78 | -------------------------------------------------------------------------------- /src/mixin.js: -------------------------------------------------------------------------------- 1 | import GenericCollection from '@/GenericCollection' 2 | import GenericModel from '@/GenericModel' 3 | 4 | const mixin = { 5 | data () { 6 | return { 7 | pg: null 8 | } 9 | }, 10 | watch: { 11 | pgConfig: { 12 | deep: true, 13 | immediate: true, 14 | async handler (cfg) { 15 | if (!cfg) return 16 | 17 | // options object with getters to pass into GenericCollection and GenericModel 18 | // this way, those will always use the current values 19 | const makeOptions = () => Object.defineProperties({}, { 20 | route: { 21 | get: () => this.$postgrest(this.pgConfig.apiRoot, this.pgConfig.token).$route(this.pgConfig.route), 22 | enumerable: true 23 | }, 24 | query: { 25 | get: () => this.pgConfig.query, 26 | enumerable: true 27 | }, 28 | limit: { 29 | get: () => this.pgConfig.limit, 30 | enumerable: true 31 | }, 32 | offset: { 33 | get: () => this.pgConfig.offset, 34 | enumerable: true 35 | }, 36 | count: { 37 | get: () => this.pgConfig.count, 38 | enumerable: true 39 | } 40 | }) 41 | if (cfg.single && !(this.pg instanceof GenericModel)) { 42 | this.pg = new GenericModel(makeOptions(), {}) 43 | } else if (!cfg.single && !(this.pg instanceof GenericCollection)) { 44 | this.pg = new GenericCollection(makeOptions()) 45 | } 46 | 47 | if (this.pg instanceof GenericCollection || cfg.query) { 48 | try { 49 | await this.pg?.$get() 50 | } catch (e) { 51 | if (this.$options.onError) { 52 | this.$options.onError.forEach(hook => hook.call(this, e)) 53 | } 54 | } 55 | } 56 | } 57 | } 58 | } 59 | } 60 | 61 | export default mixin 62 | -------------------------------------------------------------------------------- /tests/unit/Route.spec.js: -------------------------------------------------------------------------------- 1 | import Schema from '@/Schema' 2 | 3 | import request from '@/request' 4 | jest.mock('@/request') 5 | 6 | describe('Route', () => { 7 | const schema = new Schema('/api') 8 | const route = schema.$route('clients') 9 | beforeAll(() => route.$ready) 10 | 11 | beforeEach(() => { 12 | request.mockClear() 13 | }) 14 | 15 | it('has proper primary keys set', async () => { 16 | const schema = new Schema('/pk-api') 17 | await schema.$ready 18 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/pk-api', expect.anything()) 19 | 20 | const no = schema.$route('no_pk') 21 | expect(no.pks).toEqual([]) 22 | 23 | const simple = schema.$route('simple_pk') 24 | expect(simple.pks).toEqual(['id']) 25 | 26 | const composite = schema.$route('composite_pk') 27 | expect(composite.pks).toEqual(['id', 'name']) 28 | }) 29 | 30 | describe('request methods', () => { 31 | it('has properly curried request method without token', () => { 32 | route() 33 | expect(request).toHaveBeenCalledWith('/api', undefined, 'clients') 34 | }) 35 | 36 | it('has properly curried request method with token', async () => { 37 | const routeWithToken = schema('/api', 'test-token').$route('clients') 38 | routeWithToken() 39 | expect(request).toHaveBeenCalledWith('/api', 'test-token', 'clients') 40 | }) 41 | 42 | it('has properly curried request methods for OPTIONS, GET, HEAD, POST, PUT, PATCH and DELETE', () => { 43 | route.options() 44 | expect(request).toHaveBeenCalledWith('/api', undefined, 'clients', 'OPTIONS') 45 | route.get() 46 | expect(request).toHaveBeenCalledWith('/api', undefined, 'clients', 'GET') 47 | route.head() 48 | expect(request).toHaveBeenCalledWith('/api', undefined, 'clients', 'HEAD') 49 | route.post() 50 | expect(request).toHaveBeenCalledWith('/api', undefined, 'clients', 'POST') 51 | route.put() 52 | expect(request).toHaveBeenCalledWith('/api', undefined, 'clients', 'PUT') 53 | route.patch() 54 | expect(request).toHaveBeenCalledWith('/api', undefined, 'clients', 'PATCH') 55 | route.delete() 56 | expect(request).toHaveBeenCalledWith('/api', undefined, 'clients', 'DELETE') 57 | }) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /tests/unit/RPC.spec.js: -------------------------------------------------------------------------------- 1 | import RPC from '@/RPC' 2 | 3 | const request = jest.fn(() => 'returned content') 4 | 5 | describe('RPC', () => { 6 | const rpc = new RPC(request) 7 | 8 | beforeEach(() => { 9 | request.mockClear() 10 | }) 11 | 12 | it('sends a request with POST and GET', async () => { 13 | await rpc('rpc-test', { a: 1, b: 2 }, { get: false }) 14 | expect(request).toHaveBeenLastCalledWith('rpc/rpc-test', 'POST', undefined, {}, { a: 1, b: 2 }) 15 | await rpc('rpc-test', { a: 1, b: 2 }, { get: true }) 16 | expect(request).toHaveBeenLastCalledWith('rpc/rpc-test', 'GET', { a: 1, b: 2 }, {}) 17 | }) 18 | 19 | it('does not send arguments when not specified', async () => { 20 | await rpc('rpc-test', undefined, { get: false }) 21 | expect(request).toHaveBeenLastCalledWith('rpc/rpc-test', 'POST', undefined, {}, undefined) 22 | await rpc('rpc-test', undefined, { get: true }) 23 | expect(request).toHaveBeenLastCalledWith('rpc/rpc-test', 'GET', {}, {}) 24 | }) 25 | 26 | it('defaults to POST', async () => { 27 | await rpc('rpc-test') 28 | expect(request).toHaveBeenLastCalledWith('rpc/rpc-test', 'POST', undefined, {}, undefined) 29 | }) 30 | 31 | it('passes options to request properly', async () => { 32 | await rpc('rpc-test', { a: 1, b: 2 }, { 33 | get: false, 34 | query: { select: 'id' }, 35 | accept: 'binary', 36 | headers: { 'x-header': 'custom-x-header' } 37 | }) 38 | expect(request).toHaveBeenLastCalledWith('rpc/rpc-test', 'POST', { select: 'id' }, { 39 | accept: 'binary', 40 | headers: { 'x-header': 'custom-x-header' } 41 | }, { a: 1, b: 2 }) 42 | 43 | await rpc('rpc-test', { a: 1, b: 2 }, { 44 | get: true, 45 | query: { select: 'id' }, 46 | accept: 'binary', 47 | headers: { 'x-header': 'custom-x-header' } 48 | }) 49 | expect(request).toHaveBeenLastCalledWith('rpc/rpc-test', 'GET', { a: 1, b: 2, select: 'id' }, { 50 | accept: 'binary', 51 | headers: { 'x-header': 'custom-x-header' } 52 | }) 53 | }) 54 | 55 | it('returns request result', async () => { 56 | await expect(rpc('rpc-test')).resolves.toBe('returned content') 57 | await expect(rpc('rpc-test', undefined, { get: true })).resolves.toBe('returned content') 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-postgrest", 3 | "version": "0.0.0-development", 4 | "description": "Vue.js Component providing PostgREST integration", 5 | "bugs": { 6 | "url": "https://github.com/technowledgy/vue-postgrest/issues" 7 | }, 8 | "scripts": { 9 | "build": "vite build", 10 | "docs:build": "NODE_OPTIONS=--openssl-legacy-provider vuepress build docs", 11 | "docs:dev": "NODE_OPTIONS=--openssl-legacy-provider vuepress dev docs", 12 | "lint": "eslint --max-warnings=0 .", 13 | "lint:watch": "nodemon -q -x 'clear && yarn -s lint'", 14 | "test": "jest" 15 | }, 16 | "files": [ 17 | "dist/*" 18 | ], 19 | "dependencies": {}, 20 | "devDependencies": { 21 | "@babel/eslint-parser": "7.28.5", 22 | "@babel/plugin-proposal-class-properties": "7.18.6", 23 | "@babel/plugin-proposal-private-methods": "7.18.6", 24 | "@vitejs/plugin-vue2": "2.3.4", 25 | "@vue/eslint-config-standard": "8.0.1", 26 | "@vue/test-utils": "1.3.6", 27 | "@vuepress/plugin-active-header-links": "1.9.10", 28 | "@vuepress/plugin-back-to-top": "1.9.10", 29 | "core-js": "3.47.0", 30 | "coveralls": "3.1.1", 31 | "eslint": "9.39.2", 32 | "eslint-plugin-import": "2.32.0", 33 | "eslint-plugin-n": "17.23.1", 34 | "eslint-plugin-promise": "7.2.1", 35 | "eslint-plugin-standard": "5.0.0", 36 | "eslint-plugin-vue": "9.33.0", 37 | "flush-promises": "1.0.2", 38 | "globals": "16.5.0", 39 | "jest": "29.7.0", 40 | "jest-environment-jsdom": "29.7.0", 41 | "jest-fetch-mock": "3.0.3", 42 | "jest-watch-typeahead": "2.2.2", 43 | "lru-cache": "5.1.1", 44 | "nodemon": "3.1.11", 45 | "rollup-plugin-copy": "3.5.0", 46 | "semantic-release": "25.0.2", 47 | "vite": "7.2.7", 48 | "vue": "2.7.16", 49 | "vue-server-renderer": "2.7.16", 50 | "vue-template-compiler": "2.7.16", 51 | "vuepress": "1.9.10" 52 | }, 53 | "homepage": "https://github.com/technowledgy/vue-postgrest#readme", 54 | "keywords": [ 55 | "plugin", 56 | "postgres", 57 | "postgrest", 58 | "vue" 59 | ], 60 | "license": "MIT", 61 | "repository": { 62 | "type": "git", 63 | "url": "git+https://github.com/technowledgy/vue-postgrest.git" 64 | }, 65 | "type": "module", 66 | "main": "dist/cjs/vue-postgrest.js", 67 | "exports": { 68 | ".": { 69 | "require": "./dist/cjs/vue-postgrest.js", 70 | "default": "./dist/es/vue-postgrest.js" 71 | } 72 | }, 73 | "module": "dist/es/vue-postgrest.js" 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [![GitHub Actions](https://img.shields.io/github/actions/workflow/status/technowledgy/vue-postgrest/push.yaml?branch=main)](https://github.com/technowledgy/vue-postgrest/actions/workflows/push.yaml) 4 | [![Coveralls GitHub](https://img.shields.io/coveralls/github/technowledgy/vue-postgrest)](https://coveralls.io/github/technowledgy/vue-postgrest) 5 | [![Dependabot](https://img.shields.io/badge/dependabot-enabled-success)](https://github.com/technowledgy/vue-postgrest/blob/main/package.json) 6 | [![License](https://img.shields.io/npm/l/vue-postgrest)](https://github.com/technowledgy/vue-postgrest/blob/main/LICENSE) 7 | [![npm](https://img.shields.io/npm/v/vue-postgrest)](https://www.npmjs.com/package/vue-postgrest) 8 | ![vue](https://img.shields.io/badge/vue-2.x-brightgreen) 9 | 10 |
11 | 12 | # vue-postgrest 13 | Vue.js Component providing PostgREST integration 14 | 15 | ## Docs 16 | 17 | [See the official documentation](https://technowledgy.github.io/vue-postgrest/) 18 | 19 | ## Quick Start 20 | 21 | To get started, install vue-postgrest via your package manager: 22 | 23 | ``` bash 24 | yarn add vue-postgrest 25 | # OR npm install vue-postgrest 26 | ``` 27 | 28 | Import and install the Plugin in your `main.js`: 29 | 30 | ``` javascript 31 | import Vue from 'vue' 32 | import Postgrest from 'vue-postgrest' 33 | 34 | Vue.use(Postgrest) 35 | ``` 36 | 37 | You can use the `` component: 38 | 39 | ``` html 40 | 41 | 44 | 45 | ``` 46 | 47 | Use the `pg` mixin: 48 | 49 | ``` vue 50 | 53 | 54 | 69 | ``` 70 | 71 | Or you can directly use the instance method provided on your Vue instance: 72 | 73 | ``` javascript 74 | this.$postgrest.ROUTE.get() 75 | ``` 76 | 77 | ## Contributing 78 | 79 | We actively welcome your pull requests: 80 | 81 | 1. Fork the repo and create your branch from main. 82 | 2. If you've added code that should be tested, add tests. 83 | 3. If you've changed APIs, update the documentation. 84 | 4. Ensure the test suite passes. 85 | 5. Make sure your code lints. 86 | 6. Create a pull request. 87 | 88 | Thank you! 89 | -------------------------------------------------------------------------------- /src/GenericCollection.js: -------------------------------------------------------------------------------- 1 | import GenericModel from '@/GenericModel' 2 | import { createPKQuery, createReactivePrototype, mapAliasesFromSelect } from '@/utils' 3 | 4 | class GenericCollection extends Array { 5 | #options 6 | #proxy 7 | #range = {} 8 | 9 | constructor (options, ...models) { 10 | super() 11 | this.#options = options 12 | 13 | this.#proxy = new Proxy(createReactivePrototype(this, this), { 14 | get: (target, property, receiver) => { 15 | if (property === '$range') return this.#range 16 | return Reflect.get(target, property, receiver) 17 | }, 18 | set: (target, property, value, receiver) => { 19 | if (property === 'length') return Reflect.set(target, property, value, receiver) 20 | 21 | if (typeof value !== 'object' || !value) { 22 | throw new Error('Can only add objects to GenericCollection') 23 | } 24 | 25 | return Reflect.set( 26 | target, 27 | property, 28 | new GenericModel( 29 | { 30 | route: this.#options.route, 31 | select: this.#options.query?.select, 32 | query: createPKQuery(this.#options.route.pks, mapAliasesFromSelect(this.#options.query?.select, value)) 33 | }, 34 | value 35 | ), 36 | receiver 37 | ) 38 | } 39 | }) 40 | 41 | // add seed models through proxy 42 | this.#proxy.push(...models) 43 | 44 | return this.#proxy 45 | } 46 | 47 | map (...args) { 48 | return Array.from(this).map(...args) 49 | } 50 | 51 | async $get (signal, opts = {}) { 52 | await this.#options.route.$ready 53 | // remove accept and route from options, to prevent overriding it 54 | const { accept, route, query = {}, ...options } = Object.assign({}, this.#options, opts) 55 | 56 | const resp = await this.#options.route.get(query, { ...options, signal }) 57 | 58 | if (resp.headers.get('Content-Range')) { 59 | const [bounds, total] = resp.headers.get('Content-Range').split('/') 60 | const [first, last] = bounds.split('-') 61 | this.#range = { 62 | totalCount: total === '*' ? undefined : parseInt(total, 10), 63 | first: parseInt(first, 10), 64 | last: isNaN(parseInt(last, 10)) ? undefined : parseInt(last, 10) 65 | } 66 | } 67 | 68 | const body = await resp.json() 69 | 70 | // TODO: Make this model.setData for existing by using a PK map 71 | this.length = 0 72 | this.#proxy.push(...body) 73 | return body 74 | } 75 | 76 | $new (data) { 77 | const newIndex = this.#proxy.push(data) - 1 78 | return this.#proxy[newIndex] 79 | } 80 | } 81 | 82 | export default GenericCollection 83 | -------------------------------------------------------------------------------- /src/request.js: -------------------------------------------------------------------------------- 1 | import Query from '@/Query' 2 | import { throwWhenStatusNotOk } from '@/errors' 3 | 4 | let defaultHeaders 5 | 6 | export function setDefaultHeaders (headers) { 7 | defaultHeaders = new Headers(headers) 8 | } 9 | 10 | const acceptHeaderMap = { 11 | '': 'application/json', 12 | single: 'application/vnd.pgrst.object+json', 13 | binary: 'application/octet-stream', 14 | text: 'text/plain' 15 | } 16 | 17 | async function request (apiRoot, token, route, method, query = {}, options = {}, body) { 18 | const headers = new Headers(defaultHeaders) 19 | 20 | const isJSONBody = !([ 21 | Blob, 22 | FormData, 23 | URLSearchParams, 24 | // should implement ReadableStream here, but does not exist in node, so throws in tests 25 | ArrayBuffer, 26 | Int8Array, 27 | Uint8Array, 28 | Uint8ClampedArray, 29 | Int16Array, 30 | Uint16Array, 31 | Int32Array, 32 | Uint32Array, 33 | Float32Array, 34 | Float64Array, 35 | DataView, 36 | String, 37 | undefined 38 | ].includes(body?.constructor)) 39 | 40 | if (isJSONBody) { 41 | headers.set('Content-Type', 'application/json') 42 | } 43 | 44 | headers.set('Accept', acceptHeaderMap[options.accept ?? ''] || options.accept) 45 | 46 | if (options.limit === 0) { 47 | // this will be an unsatisfiable range, but the user wanted it! 48 | headers.set('Range-Unit', 'items') 49 | headers.set('Range', '-0') 50 | } else if (options.limit || options.offset !== undefined) { 51 | const lower = options.offset ?? 0 52 | const upper = options.limit ? lower + options.limit - 1 : '' 53 | headers.set('Range-Unit', 'items') 54 | headers.set('Range', [lower, upper].join('-')) 55 | } 56 | 57 | const prefer = ['return', 'count', 'params', 'resolution'] 58 | .filter(key => options[key]) 59 | .map(key => `${key}=${options[key]}`) 60 | .join(',') 61 | if (prefer) { 62 | headers.append('Prefer', prefer) 63 | } 64 | 65 | if (token) { 66 | headers.set('Authorization', `Bearer ${token}`) 67 | } 68 | 69 | // overwrite headers with custom headers if set 70 | if (options.headers) { 71 | for (const [k, v] of Object.entries(options.headers)) { 72 | headers.set(k, v) 73 | } 74 | } 75 | 76 | const url = new Query(apiRoot, route, query) 77 | 78 | // send all body types the fetch api recognizes as described here https://developer.mozilla.org/de/docs/Web/API/WindowOrWorkerGlobalScope/fetch as-is, stringify the rest 79 | return await fetch(url.toString(), { 80 | method, 81 | headers, 82 | body: isJSONBody ? JSON.stringify(body) : body, 83 | signal: options.signal 84 | }).then(throwWhenStatusNotOk) 85 | } 86 | 87 | export default request 88 | -------------------------------------------------------------------------------- /.github/workflows/push.yaml: -------------------------------------------------------------------------------- 1 | name: Push to main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint: 10 | name: Lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v6.0.1 14 | - uses: actions/setup-node@v6.1.0 15 | with: 16 | cache: yarn 17 | check-latest: true 18 | node-version: 24 19 | - run: yarn 20 | - run: yarn lint --no-fix 21 | 22 | build: 23 | name: Build 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v6.0.1 27 | - uses: actions/setup-node@v6.1.0 28 | with: 29 | cache: yarn 30 | check-latest: true 31 | node-version: 24 32 | - run: yarn 33 | - run: yarn build 34 | 35 | coverage: 36 | name: Test Coverage 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v6.0.1 40 | - uses: actions/setup-node@v6.1.0 41 | with: 42 | cache: yarn 43 | check-latest: true 44 | node-version: 24 45 | - run: yarn 46 | - run: yarn test 47 | - uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6 48 | with: 49 | github-token: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | test: 52 | name: Test LTS 53 | runs-on: ubuntu-latest 54 | strategy: 55 | fail-fast: false 56 | matrix: 57 | node: 58 | - 22 59 | steps: 60 | - uses: actions/checkout@v6.0.1 61 | - uses: actions/setup-node@v6.1.0 62 | with: 63 | cache: yarn 64 | node-version: ${{ matrix.node }} 65 | - run: yarn 66 | - run: yarn test 67 | 68 | docs: 69 | name: Build & Deploy Docs 70 | runs-on: ubuntu-latest 71 | steps: 72 | - uses: actions/checkout@v6.0.1 73 | - uses: actions/setup-node@v6.1.0 74 | with: 75 | cache: yarn 76 | check-latest: true 77 | node-version: 24 78 | - run: yarn 79 | - run: yarn docs:build 80 | - uses: JamesIves/github-pages-deploy-action@v4.7.6 81 | with: 82 | branch: gh-pages 83 | folder: docs/.vuepress/dist 84 | 85 | release: 86 | name: Release 87 | runs-on: ubuntu-latest 88 | needs: 89 | - lint 90 | - build 91 | - coverage 92 | - test 93 | - docs 94 | permissions: 95 | contents: write 96 | id-token: write 97 | issues: write 98 | pull-requests: write 99 | steps: 100 | - uses: actions/checkout@v6.0.1 101 | - uses: actions/setup-node@v6.1.0 102 | with: 103 | cache: yarn 104 | check-latest: true 105 | node-version: 24 106 | - run: yarn 107 | # TODO: Possibly download as artifact 108 | - run: yarn build 109 | - env: 110 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 111 | run: yarn semantic-release 112 | -------------------------------------------------------------------------------- /src/utils/diff.js: -------------------------------------------------------------------------------- 1 | import { reflectHelper } from './reflect' 2 | 3 | // use symbols for the internal props/methods to make them private 4 | const $diff = Symbol('diff') 5 | const $freeze = Symbol('freeze') 6 | const $isDiffProxy = Symbol('isDiffProxy') 7 | 8 | function createDiffProxy (target, parentDirty = false) { 9 | const base = Array.isArray(target) ? [] : {} 10 | copy(target, base) 11 | return new Proxy(target, { 12 | get (target, property, receiver) { 13 | switch (property) { 14 | case $diff: 15 | return Object.fromEntries(Object.entries(target) 16 | .filter(([k, v]) => v !== base[k] || (v && v[$isDiffProxy] && v.$isDirty)) 17 | ) 18 | 19 | case $freeze: 20 | return () => { 21 | parentDirty = false 22 | copy(target, base, $freeze) 23 | } 24 | 25 | case $isDiffProxy: return true 26 | 27 | case '$isDirty': 28 | if (parentDirty) return true 29 | if (Array.isArray(target)) { 30 | if (target.length !== base.length) return true 31 | return target.filter((v, k) => v !== base[k] || (v && v[$isDiffProxy] && v.$isDirty)).length > 0 32 | } else { 33 | if (Object.keys(base).filter(k => !(k in target)).length > 0) return true 34 | return Object.entries(target).filter(([k, v]) => v !== base[k] || (v && v[$isDiffProxy] && v.$isDirty)).length > 0 35 | } 36 | 37 | case '$reset': 38 | return () => copy(base, target, '$reset') 39 | } 40 | return Reflect.get(target, property, receiver) 41 | }, 42 | set (target, property, value, receiver) { 43 | if (typeof value === 'object' && value !== null && !value[$isDiffProxy]) { 44 | value = createDiffProxy(value, true) 45 | } 46 | return reflectHelper.call('set', [$diff, $freeze, $isDiffProxy, '$isDirty', '$reset'], false, target, property, value, receiver) 47 | }, 48 | defineProperty: reflectHelper.bind('defineProperty', [$diff, $freeze, $isDiffProxy, '$isDirty', '$reset'], false), 49 | deleteProperty: reflectHelper.bind('deleteProperty', [$diff, $freeze, $isDiffProxy, '$isDirty', '$reset'], false), 50 | getOwnPropertyDescriptor: reflectHelper.bind('getOwnPropertyDescriptor', [$diff, $freeze, $isDiffProxy, '$isDirty', '$reset'], undefined), 51 | has: reflectHelper.bind('has', [$diff, $freeze, $isDiffProxy, '$isDirty', '$reset'], true) 52 | }) 53 | } 54 | 55 | // copies target keys to base and creates DiffProxies everywhere 56 | function copy (target, base, recurse) { 57 | Object.entries(target).forEach(([k, v]) => { 58 | if (typeof v === 'object' && v !== null) { 59 | if (v[$isDiffProxy]) { 60 | v[recurse]?.() 61 | } else { 62 | target[k] = createDiffProxy(v) 63 | } 64 | } 65 | // create base copy for diffs and reset 66 | base[k] = target[k] 67 | }) 68 | } 69 | 70 | export { 71 | // we need to access the first two props in GenericModel 72 | $diff, 73 | $freeze, 74 | createDiffProxy 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/reactivity.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import ObservableFunction from '@/ObservableFunction' 3 | import { reflectHelper } from './reflect' 4 | 5 | function createReactivePrototype (target, boundThis) { 6 | // adds observer to target through helper object to avoid vue internal check of isPlainObject 7 | const reactiveBase = Vue.observable(Array.isArray(target) ? [] : {}) 8 | Object.defineProperties(target, Object.getOwnPropertyDescriptors(reactiveBase)) 9 | target.__ob__.value = target 10 | // split the target prototype object into constructor and prototype props 11 | const targetProto = Object.getPrototypeOf(target) 12 | const { constructor, ...props } = Object.getOwnPropertyDescriptors(targetProto) 13 | const keys = Object.keys(props) 14 | // copies the vue-augmented prototype and adds the target constructor 15 | const reactiveProto = Object.getPrototypeOf(reactiveBase) 16 | Object.setPrototypeOf(target, Object.create(targetProto, { 17 | ...Object.getOwnPropertyDescriptors(reactiveProto), 18 | constructor 19 | })) 20 | // add the target prototype's properties non-enumerable on the instance for reactivity 21 | // to add vue's reactive getters and setters, set them enumerable first and then change that 22 | // using temporary Proxies to hook into defineProperty to change enumerability 23 | const makeEnumerableAndObservableProxy = new Proxy(target, { 24 | defineProperty: (target, key, descriptor) => { 25 | descriptor.enumerable = true 26 | if (descriptor.value instanceof Function) { 27 | descriptor.value = descriptor.value.bind(boundThis) 28 | if (descriptor.value[Symbol.toStringTag] === 'AsyncFunction') { 29 | descriptor.value = new ObservableFunction(descriptor.value) 30 | } 31 | } 32 | return Reflect.defineProperty(target, key, descriptor) 33 | } 34 | }) 35 | Object.defineProperties(makeEnumerableAndObservableProxy, props) 36 | const makeNonEnumerableProxy = new Proxy(target, { 37 | defineProperty: (target, key, descriptor) => { 38 | // only turn enumeration off for proto keys 39 | // target could already have data, that should stay untouched during ob.walk 40 | if (keys.includes(key)) { 41 | descriptor.enumerable = false 42 | } 43 | return Reflect.defineProperty(target, key, descriptor) 44 | } 45 | }) 46 | // copy observer's walk method to pick up on the newly created props 47 | function walk (obj) { 48 | const keys = Object.keys(obj) 49 | for (let i = 0; i < keys.length; i++) { 50 | const key = keys[i] 51 | Vue.util.defineReactive(obj, key) 52 | } 53 | } 54 | walk(makeNonEnumerableProxy) 55 | // wrap in proxy to hide instanced proto props 56 | return new Proxy(target, { 57 | defineProperty: reflectHelper.bind('defineProperty', keys, false), 58 | deleteProperty: reflectHelper.bind('deleteProperty', keys, false), 59 | getOwnPropertyDescriptor: reflectHelper.bind('getOwnPropertyDescriptor', keys, undefined), 60 | ownKeys: (target) => { 61 | return Reflect.ownKeys(target).filter(k => !keys.includes(k)) 62 | }, 63 | set: reflectHelper.bind('set', keys, false) 64 | }) 65 | } 66 | 67 | export { createReactivePrototype } 68 | -------------------------------------------------------------------------------- /tests/unit/Plugin.spec.js: -------------------------------------------------------------------------------- 1 | import { createLocalVue } from '@vue/test-utils' 2 | import Plugin, { setDefaultToken } from '@/index' 3 | import Schema, { resetSchemaCache } from '@/Schema' 4 | import Route from '@/Route' 5 | 6 | describe('Plugin', () => { 7 | let localVue 8 | 9 | beforeEach(() => { 10 | localVue = createLocalVue() 11 | }) 12 | 13 | afterEach(() => { 14 | fetch.mockClear() 15 | resetSchemaCache() 16 | }) 17 | 18 | describe('Plugin installation', () => { 19 | it('registers a global component', () => { 20 | expect(localVue.options.components.postgrest).toBeUndefined() 21 | localVue.use(Plugin) 22 | expect(localVue.options.components.postgrest).toBeTruthy() 23 | }) 24 | 25 | describe('$postgrest', () => { 26 | beforeEach(async () => { 27 | localVue.use(Plugin, { 28 | apiRoot: '/api', 29 | headers: { 30 | Prefer: 'timezone=Europe/Berlin' 31 | } 32 | }) 33 | await localVue.prototype.$postgrest.$ready 34 | }) 35 | 36 | it('registers apiRoot Schema as $postgrest on the Vue prototype', async () => { 37 | expect(localVue.prototype.$postgrest).toBeInstanceOf(Schema) 38 | expect(fetch).toHaveBeenCalledWith('http://localhost/api', expect.anything()) 39 | }) 40 | 41 | it('exposes routes', () => { 42 | expect(localVue.prototype.$postgrest.clients).toBeInstanceOf(Route) 43 | }) 44 | 45 | it('allows request', async () => { 46 | const resp = await localVue.prototype.$postgrest.clients.get() 47 | const body = await resp.json() 48 | expect(fetch).toHaveBeenCalledWith('http://localhost/api/clients', expect.objectContaining({ 49 | headers: new Headers({ 50 | Accept: 'application/json', 51 | Prefer: 'timezone=Europe/Berlin' 52 | }) 53 | })) 54 | expect(body).toEqual([ 55 | { 56 | id: 1, 57 | name: 'Test Client 1' 58 | }, 59 | { 60 | id: 2, 61 | name: 'Test Client 2' 62 | }, 63 | { 64 | id: 3, 65 | name: 'Test Client 3' 66 | } 67 | ]) 68 | }) 69 | }) 70 | 71 | describe('setDefaultToken', () => { 72 | it('uses new default token', async () => { 73 | localVue.use(Plugin, { 74 | apiRoot: '/api' 75 | }) 76 | await localVue.prototype.$postgrest.$ready 77 | await localVue.prototype.$postgrest.clients.get() 78 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 79 | headers: new Headers({ 80 | Accept: 'application/json' 81 | }) 82 | })) 83 | 84 | setDefaultToken('token') 85 | await localVue.prototype.$postgrest.$ready 86 | await localVue.prototype.$postgrest.clients.get() 87 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 88 | headers: new Headers({ 89 | Accept: 'application/json', 90 | Authorization: 'Bearer token' 91 | }) 92 | })) 93 | }) 94 | }) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /src/Schema.js: -------------------------------------------------------------------------------- 1 | import Route from '@/Route' 2 | import RPC from '@/RPC' 3 | import request from '@/request' 4 | import { throwWhenStatusNotOk, SchemaNotFoundError } from '@/errors' 5 | import ObservableFunction from '@/ObservableFunction' 6 | 7 | let schemaCache = {} 8 | 9 | // just for test env 10 | export function resetSchemaCache () { 11 | schemaCache = {} 12 | } 13 | 14 | let defaultApiRoot = '/' 15 | let defaultToken 16 | 17 | export function setDefaultRoot (apiRoot = defaultApiRoot) { 18 | defaultApiRoot = apiRoot 19 | } 20 | 21 | export function setDefaultToken (token) { 22 | defaultToken = token 23 | } 24 | 25 | export default class Schema extends Function { 26 | #apiRoot 27 | #token 28 | 29 | constructor (apiRoot = defaultApiRoot, token = defaultToken) { 30 | super('', 'return arguments.callee._call.apply(arguments.callee, arguments)') 31 | const cached = schemaCache[apiRoot] && schemaCache[apiRoot][token] 32 | if (cached) return cached 33 | // create new Instance 34 | this.#apiRoot = apiRoot 35 | this.#token = token 36 | if (!schemaCache[apiRoot]) { 37 | schemaCache[apiRoot] = {} 38 | } 39 | schemaCache[apiRoot][token] = this 40 | // eslint-disable-next-line no-async-promise-executor 41 | const ready = new Promise(async (resolve, reject) => { 42 | try { 43 | const schema = await this._fetchSchema(apiRoot, token) 44 | for (const path of Object.keys(schema.paths ?? {})) { 45 | if (path.startsWith('/rpc/')) { 46 | const fn = path.substring(5) 47 | this.rpc[fn] = new ObservableFunction(this.rpc.bind(this.rpc, fn)) 48 | } else { 49 | const route = path.substring(1) 50 | this._createRoute(route) 51 | } 52 | } 53 | for (const [route, def] of Object.entries(schema.definitions ?? {})) { 54 | this._createRoute(route, def) 55 | } 56 | resolve() 57 | } catch (e) { 58 | reject(e) 59 | } 60 | }) 61 | // non-enumerable $ready prop returning the promise, just for tests 62 | Object.defineProperty(this, '$ready', { 63 | value: ready 64 | }) 65 | this.rpc = new RPC(request.bind(null, this.#apiRoot, this.#token), this.$ready) 66 | } 67 | 68 | _call (apiRoot = this.#apiRoot, token = this.#token) { 69 | return new Schema(apiRoot, token) 70 | } 71 | 72 | $route (route) { 73 | return this._createRoute(route) 74 | } 75 | 76 | _createRoute (route, def) { 77 | if (!this[route]) { 78 | this[route] = new Route(request.bind(null, this.#apiRoot, this.#token, route), this.$ready) 79 | } 80 | if (def) { 81 | this[route]._extractFromDefinition(def) 82 | } 83 | return this[route] 84 | } 85 | 86 | async _fetchSchema (apiRoot, token) { 87 | const headers = new Headers() 88 | if (token) { 89 | headers.set('Authorization', `Bearer ${token}`) 90 | } 91 | try { 92 | const url = new URL(apiRoot, window.location.href) 93 | const resp = await fetch(url.toString(), { headers }).then(throwWhenStatusNotOk) 94 | const body = await resp.json() 95 | if (!resp.headers.get('Content-Type').startsWith('application/openapi+json')) { 96 | throw new Error('wrong body format') 97 | } 98 | return body 99 | } catch (err) { 100 | throw new SchemaNotFoundError(apiRoot, err) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/unit/Component.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Plugin from '@/Plugin' 3 | import { shallowMount } from '@vue/test-utils' 4 | import Postgrest from '@/Postgrest' 5 | import GenericModel from '@/GenericModel' 6 | import ObservableFunction from '@/ObservableFunction' 7 | import { AuthError, FetchError } from '@/index' 8 | 9 | Vue.use(Plugin, { 10 | apiRoot: '/api' 11 | }) 12 | 13 | function createComponent (props, cb) { 14 | const render = jest.fn(cb) 15 | const wrapper = shallowMount(Postgrest, { 16 | propsData: props, 17 | listeners: { 18 | /* eslint-disable n/no-callback-literal */ 19 | error: evt => cb('error', evt) 20 | }, 21 | scopedSlots: { 22 | default: render 23 | } 24 | }) 25 | return { 26 | render, 27 | wrapper 28 | } 29 | } 30 | 31 | describe('Component', () => { 32 | let render 33 | let wrapper 34 | // fallback for tests that don't set wrapper 35 | beforeEach(() => { 36 | wrapper = { 37 | destroy () {} 38 | } 39 | }) 40 | afterEach(() => wrapper.destroy()) 41 | 42 | it('registers component', () => { 43 | expect(() => createComponent({ route: 'clients' })).not.toThrow() 44 | }) 45 | 46 | describe('Slot scope', () => { 47 | it('provides observable $get function', () => { 48 | ({ render, wrapper } = createComponent({ route: 'clients' })) 49 | expect(render.mock.calls[0][0].$get).toBeInstanceOf(ObservableFunction) 50 | }) 51 | 52 | it('provides GenericCollection if single is not set', async () => { 53 | ({ render, wrapper } = await new Promise(resolve => { 54 | const cc = createComponent({ route: 'clients', query: {} }, async (items) => { 55 | if (!items.$get.isPending) resolve(cc) 56 | }) 57 | })) 58 | // checking for Array here because Vue 2.x reactivity doesn't allow us to expose GenericCollection as the true prototype 59 | expect(render).toHaveBeenCalledWith(expect.any(Array)) 60 | }) 61 | 62 | it('provides GenericModel if single is true', async () => { 63 | ({ render, wrapper } = await new Promise(resolve => { 64 | const cc = createComponent({ route: 'clients', query: {}, single: true }, async (item) => { 65 | if (!item.$get.isPending) resolve(cc) 66 | }) 67 | })) 68 | expect(render).toHaveBeenCalledWith(expect.any(GenericModel)) 69 | }) 70 | 71 | it('provides $range if available', async () => { 72 | ({ render, wrapper } = await new Promise(resolve => { 73 | const cc = createComponent({ route: 'clients', query: {}, limit: 2 }, async (items) => { 74 | if (!items.$get.isPending) resolve(cc) 75 | }) 76 | })) 77 | expect(render.mock.calls[2][0].$range).toMatchObject({ 78 | totalCount: undefined, 79 | first: 0, 80 | last: 1 81 | }) 82 | }) 83 | }) 84 | 85 | describe('error handling', () => { 86 | it('emits "error" with "invalid_token" when using expired-token', async () => { 87 | ({ render, wrapper } = await new Promise(resolve => { 88 | const cc = createComponent({ route: 'clients', query: {}, token: 'expired-token' }, (evt, err) => { 89 | if (typeof evt === 'string') { 90 | expect(evt).toBe('error') 91 | expect(err).toBeInstanceOf(AuthError) 92 | expect(err).toMatchObject({ error: 'invalid_token', error_description: 'JWT expired' }) 93 | resolve(cc) 94 | } 95 | }) 96 | })) 97 | }) 98 | 99 | it('emits "error" with status "404" when failing with 404', async () => { 100 | ({ render, wrapper } = await new Promise(resolve => { 101 | const cc = createComponent({ route: '404', query: {} }, (evt, err) => { 102 | if (typeof evt === 'string') { 103 | expect(evt).toBe('error') 104 | expect(err).toBeInstanceOf(FetchError) 105 | expect(err).toMatchObject({ status: 404 }) 106 | resolve(cc) 107 | } 108 | }) 109 | })) 110 | }) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /src/GenericModel.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { PrimaryKeyError } from '@/errors' 3 | import { $diff, $freeze, createDiffProxy, createPKQuery, createReactivePrototype, mapAliasesFromSelect } from '@/utils' 4 | 5 | class GenericModel { 6 | #options 7 | #proxy 8 | 9 | constructor (options, data) { 10 | this.#options = options 11 | Object.assign(this, data) 12 | this.#proxy = createReactivePrototype(createDiffProxy(this), this) 13 | return this.#proxy 14 | } 15 | 16 | async #request ({ method, keepChanges = false, needsQuery = true }, signal, opts, ...data) { 17 | await this.#options.route.$ready 18 | const { columns, ...options } = opts 19 | 20 | const query = { select: this.#options.select } 21 | 22 | if (needsQuery) { 23 | const q = this.#options.query 24 | if (!q) throw new PrimaryKeyError() 25 | if (q instanceof PrimaryKeyError) throw q 26 | Object.assign(query, q) 27 | } 28 | 29 | if (columns) { 30 | if (this.#options.route.columns) { 31 | query.columns = columns.filter(c => this.#options.route.columns.includes(c)) 32 | } else { 33 | query.columns = columns 34 | } 35 | } 36 | 37 | // rename aliased columns and drop columns that don't exist on the route (e.g. joined columns) 38 | data = data.map(data => { 39 | return Object.fromEntries( 40 | Object.entries(mapAliasesFromSelect(this.#options.select, data)) 41 | .filter(([col, v]) => !this.#options.route.columns || this.#options.route.columns.includes(col)) 42 | ) 43 | }) 44 | 45 | const resp = await this.#options.route[method](query, { ...options, accept: 'single', signal }, ...data) 46 | 47 | let body 48 | try { 49 | body = await resp.json() 50 | } catch { 51 | if (!resp.headers.get('Location')) return 52 | // for POST/PUT minimal 53 | const loc = new URLSearchParams(resp.headers.get('Location').replace(/^\/[^?]+\?/, '')) 54 | return Object.fromEntries(Array.from(loc.entries()).map(([key, value]) => [key, value.replace(/^eq\./, '')])) 55 | } 56 | 57 | // update instance with returned data 58 | // TODO: do we need to delete missing keys? 59 | if (keepChanges) { 60 | const diff = this.#proxy[$diff] 61 | Object.entries(body).forEach(([key, value]) => Vue.set(this.#proxy, key, value)) 62 | this.#proxy[$freeze]() 63 | Object.entries(diff).forEach(([key, value]) => Vue.set(this.#proxy, key, value)) 64 | } else { 65 | Object.entries(body).forEach(([key, value]) => Vue.set(this.#proxy, key, value)) 66 | this.#proxy[$freeze]() 67 | } 68 | return body 69 | } 70 | 71 | async $get (signal, opts = {}) { 72 | const { keepChanges, ...options } = opts 73 | return this.#request({ method: 'get', keepChanges }, signal, options) 74 | } 75 | 76 | async $post (signal, opts = {}) { 77 | const options = { return: 'representation', ...opts } 78 | const body = await this.#request({ method: 'post', needsQuery: false }, signal, options, this.#proxy) 79 | if (body) { 80 | // we need to make sure the query is updated with the primary key 81 | this.#options.query = createPKQuery(this.#options.route.pks, mapAliasesFromSelect(this.#options.query?.select, body)) 82 | } 83 | return body 84 | } 85 | 86 | async $put (signal, opts) { 87 | const options = { return: 'representation', ...opts } 88 | return this.#request({ method: 'put' }, signal, options, this.#proxy) 89 | } 90 | 91 | async $patch (signal, opts, data = {}) { 92 | const options = { return: 'representation', ...opts } 93 | 94 | if (!data || typeof data !== 'object') { 95 | throw new Error('Patch data must be an object.') 96 | } 97 | const patchData = Object.assign( 98 | {}, 99 | this.#proxy[$diff], 100 | data 101 | ) 102 | 103 | if (Object.keys(patchData).length === 0) { 104 | // avoid sending an empty patch request 105 | return this.#proxy 106 | } 107 | 108 | return this.#request({ method: 'patch' }, signal, options, patchData) 109 | } 110 | 111 | async $delete (signal, options = {}) { 112 | return this.#request({ method: 'delete' }, signal, options) 113 | } 114 | } 115 | 116 | export default GenericModel 117 | -------------------------------------------------------------------------------- /tests/fetch.mock.js: -------------------------------------------------------------------------------- 1 | const apiSchema = { 2 | paths: { 3 | '/clients': {}, 4 | '/other': {}, 5 | '/rpc/authenticate': {} 6 | }, 7 | definitions: { 8 | clients: { 9 | properties: { 10 | id: { 11 | type: 'integer', 12 | description: 'Note:\nThis is a Primary Key.' 13 | }, 14 | name: {}, 15 | age: {}, 16 | level: {}, 17 | arr: {}, 18 | nestedField: {}, 19 | new: {} 20 | } 21 | } 22 | } 23 | } 24 | 25 | const clientsData = [ 26 | { 27 | id: 1, 28 | name: 'Test Client 1' 29 | }, 30 | { 31 | id: 2, 32 | name: 'Test Client 2' 33 | }, 34 | { 35 | id: 3, 36 | name: 'Test Client 3' 37 | } 38 | ] 39 | 40 | const pkSchema = { 41 | paths: {}, 42 | definitions: { 43 | no_pk: { 44 | properties: { 45 | age: { 46 | type: 'integer', 47 | description: 'This is not a primary key.' 48 | } 49 | } 50 | }, 51 | simple_pk: { 52 | properties: { 53 | id: { 54 | type: 'integer', 55 | description: 'Note:\nThis is a Primary Key.' 56 | }, 57 | age: { 58 | type: 'integer', 59 | description: 'This is not a primary key.' 60 | } 61 | } 62 | }, 63 | composite_pk: { 64 | properties: { 65 | id: { 66 | type: 'integer', 67 | description: 'Note:\nThis is a Primary Key.' 68 | }, 69 | name: { 70 | type: 'text', 71 | description: 'Note:\nThis is a Primary Key.' 72 | }, 73 | age: { 74 | type: 'integer', 75 | description: 'This is not a primary key.' 76 | } 77 | } 78 | } 79 | } 80 | } 81 | 82 | export default async req => { 83 | const url = req.url.replace('http://localhost', '') 84 | switch (url) { 85 | case '/': 86 | case '/api': 87 | case '/api/': 88 | case '/another-api': 89 | case '/another-api/': 90 | return { 91 | body: JSON.stringify(apiSchema), 92 | init: { 93 | status: 200, 94 | statusText: 'OK', 95 | headers: { 96 | 'Content-Type': 'application/openapi+json' 97 | } 98 | } 99 | } 100 | case '/pk-api': 101 | case '/pk-api2': 102 | return { 103 | body: JSON.stringify(pkSchema), 104 | init: { 105 | status: 200, 106 | statusText: 'OK', 107 | headers: { 108 | 'Content-Type': 'application/openapi+json' 109 | } 110 | } 111 | } 112 | case '/empty': 113 | return { 114 | body: JSON.stringify({}), 115 | init: { 116 | status: 200, 117 | statusText: 'OK', 118 | headers: { 119 | 'Content-Type': 'application/openapi+json' 120 | } 121 | } 122 | } 123 | case '/text': 124 | return { 125 | body: 'just some text', 126 | init: { 127 | status: 200, 128 | statusText: 'OK', 129 | headers: { 130 | 'Content-Type': 'text/csv' 131 | } 132 | } 133 | } 134 | case '/json': 135 | return { 136 | body: JSON.stringify({ 137 | just: 'some', 138 | json: 'data' 139 | }), 140 | init: { 141 | status: 200, 142 | statusText: 'OK', 143 | headers: { 144 | 'Content-Type': 'text/csv' 145 | } 146 | } 147 | } 148 | case '/404': 149 | case '/api/404': 150 | return { 151 | body: JSON.stringify({ 152 | hint: '404 error hint', 153 | details: '404 error details', 154 | code: '404 error code', 155 | message: '404 error message' 156 | }), 157 | init: { 158 | status: 404, 159 | statusText: 'Not found', 160 | headers: { 161 | 'Content-Type': 'application/json' 162 | } 163 | } 164 | } 165 | default: 166 | if (req.headers.get('Authorization') === 'Bearer expired-token') { 167 | return { 168 | body: JSON.stringify({ 169 | hint: '401 error hint', 170 | details: '401 error details', 171 | code: '401 error code', 172 | message: '401 error message' 173 | }), 174 | init: { 175 | status: 401, 176 | statusText: 'Not authorized', 177 | headers: { 178 | 'WWW-Authenticate': 'Bearer error="invalid_token", error_description="JWT expired"' 179 | } 180 | } 181 | } 182 | } else if (req.headers.get('Accept') === 'application/vnd.pgrst.object+json') { 183 | return { 184 | body: JSON.stringify(clientsData[0]), 185 | init: { 186 | status: 200, 187 | statusText: 'OK', 188 | headers: { 189 | 'Content-Type': 'application/json' 190 | } 191 | } 192 | } 193 | } else { 194 | const rangeHeaders = {} 195 | if (req.headers.get('Range') || req.headers.get('Prefer')?.includes('count=exact')) { 196 | rangeHeaders['Range-Units'] = 'items' 197 | rangeHeaders['Content-Range'] = req.headers.get('Prefer')?.includes('count=exact') ? '0-/3' : '0-1/*' 198 | } 199 | return { 200 | body: JSON.stringify(clientsData), 201 | init: { 202 | status: 200, 203 | statusText: 'OK', 204 | headers: { 205 | 'Content-Type': 'application/json', 206 | ...rangeHeaders 207 | } 208 | } 209 | } 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/Query.js: -------------------------------------------------------------------------------- 1 | function isLogicalOperator (k) { 2 | return ['and', 'or', 'not.and', 'not.or'].includes(k) 3 | } 4 | 5 | function parseKey (key) { 6 | if (!key.includes('.')) return [key] 7 | 8 | const parts = key.split('.') 9 | const operator = parts.at(-1) 10 | const not = parts.at(-2) === 'not' 11 | const field = parts.slice(0, not ? -2 : -1).join('.') 12 | 13 | if (not) return [field, 'not', operator] 14 | return [field, operator] 15 | } 16 | 17 | function quoteValue (str) { 18 | str = str.toString() 19 | if ([',', '.', ':', '(', ')'].find(r => str.includes(r)) || ['null', 'true', 'false'].includes(str)) { 20 | return `"${str}"` 21 | } else { 22 | return str 23 | } 24 | } 25 | 26 | // conditional concat - only if str is set 27 | function cc (prefix, str, suffix = '') { 28 | str = str?.toString() 29 | return str ? `${prefix}${str}${suffix}` : '' 30 | } 31 | 32 | class Query extends URL { 33 | subQueries = {} 34 | #apiRoot 35 | 36 | constructor (apiRoot, route, queryObject = {}) { 37 | const url = (apiRoot.replace(/\/$/, '') + '/' + route.replace(/^\//, '')) 38 | super(url, window.location.href) 39 | this.#apiRoot = apiRoot 40 | /* eslint-disable camelcase */ 41 | const { columns, select, order, limit, offset, on_conflict, ...conditions } = queryObject 42 | if (on_conflict) this.searchParams.append('on_conflict', on_conflict) 43 | /* eslint-enable camelcase */ 44 | if (columns) this.searchParams.append('columns', columns) 45 | this._appendSelect(select) 46 | this._appendOrder(order) 47 | this._appendLimit(limit) 48 | this._appendOffset(offset) 49 | this._appendConditions(conditions) 50 | this._appendSubQueryParams(this) 51 | } 52 | 53 | _appendSubQueryParams (parent, aliasChain = '') { 54 | for (let [alias, query] of Object.entries(parent.subQueries)) { 55 | alias = cc(`${aliasChain}`, alias) 56 | for (const [key, value] of query.searchParams.entries()) { 57 | // columns are not merged with subqueries and select is handled in _parseSelectObject 58 | if (['columns', 'select'].includes(key)) continue 59 | this.searchParams.append(`${alias}.${key}`, value) 60 | } 61 | this._appendSubQueryParams(query, alias) 62 | } 63 | } 64 | 65 | _appendSelect (select) { 66 | if (typeof select === 'object' && !Array.isArray(select)) { 67 | this.searchParams.append('select', this._parseSelectObject(select)) 68 | } else if (select) { 69 | this.searchParams.append('select', select.toString()) 70 | } 71 | } 72 | 73 | _parseSelectObject (obj, jsonChain = []) { 74 | return Object.entries(obj).map(([k, v]) => { 75 | // ignore falsy values - will be filtered out 76 | if (!v) return false 77 | // embedding resources with sub queries 78 | if (v?.select) { 79 | const alias = k.split(':', 1)[0].split('!', 1)[0] 80 | const subQuery = new Query(this.#apiRoot, alias, v) 81 | this.subQueries[alias] = subQuery 82 | return `${k}(${subQuery.searchParams.get('select')})` 83 | } 84 | // regular select 85 | let alias = ''; let field; let cast = ''; let subfields = [] 86 | if (/^[^"]*:/.test(k)) { 87 | // first `:` is outside quotes 88 | [alias, field] = k.split(/:(.+)/) 89 | } else if (/^".*[^\\]":/.test(k)) { 90 | // quoted alias 91 | [alias, field] = k.split(/(?<=[^\\]"):(.+)/) 92 | } else { 93 | field = k 94 | } 95 | if (typeof v === 'string') { 96 | cast = v 97 | } else if (typeof v === 'object') { // json-fields 98 | let fields 99 | ({ '::': cast, ...fields } = v) 100 | subfields = this._parseSelectObject(fields, [...jsonChain, field]) 101 | // only select the json-field itself, if either alias or cast are set or no subfields are available 102 | if (subfields.length > 0 && !alias && !cast) { 103 | return subfields 104 | } 105 | } 106 | return [ 107 | cc('', alias, ':') + [...jsonChain, field].join('->') + cc('::', cast), 108 | subfields 109 | ] 110 | }).flat(2).filter(Boolean).join(',') 111 | } 112 | 113 | _appendOrder (order) { 114 | if (Array.isArray(order)) { 115 | this.searchParams.append('order', order.map(item => { 116 | if (Array.isArray(item)) { 117 | return item.join('.') 118 | } else { 119 | return item 120 | } 121 | }).join(',')) 122 | } else if (typeof order === 'object') { 123 | this.searchParams.append('order', Object.entries(order).map(([k, v]) => { 124 | if (v && typeof v === 'string') { 125 | return `${k}.${v}` 126 | } else { 127 | return k 128 | } 129 | }).join(',')) 130 | } else if (order) { 131 | this.searchParams.append('order', order) 132 | } 133 | } 134 | 135 | _appendLimit (limit) { 136 | if (limit) { 137 | this.searchParams.append('limit', limit) 138 | } 139 | } 140 | 141 | _appendOffset (offset) { 142 | if (offset) { 143 | this.searchParams.append('offset', offset) 144 | } 145 | } 146 | 147 | _appendConditions (obj) { 148 | for (const { key, value } of this._parseConditions(obj)) { 149 | this.searchParams.append(key, value) 150 | } 151 | } 152 | 153 | _parseConditions (obj, jsonPrefix = '', quoteStrings = false) { 154 | return Object.entries(obj).map(([key, value]) => { 155 | if (value === undefined) return false 156 | // throw away alias - just used to allow the same condition more than once on one object 157 | const aliasKey = key.split(':') 158 | key = aliasKey[1] ?? aliasKey[0] 159 | if (isLogicalOperator(key)) { 160 | if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('no object for logical operator') 161 | if (jsonPrefix) throw new Error('logical operators can\'t be nested with json operators') 162 | const strValue = this._parseConditions(value, '', true).map(({ key: k, value: v }) => { 163 | return isLogicalOperator(k) ? `${k}${v}` : `${k}.${v}` 164 | }).join(',') 165 | if (!strValue) return undefined 166 | return { 167 | key, 168 | value: `(${strValue})` 169 | } 170 | } else { 171 | const [field, ...ops] = parseKey(key) 172 | let strValue 173 | switch (ops[ops.length - 1]) { 174 | case 'in': 175 | strValue = this._valueToString(value, '()', true) 176 | break 177 | case undefined: 178 | // no operator + object = nested json 179 | if (value && typeof value === 'object' && !Array.isArray(value)) { 180 | return this._parseConditions(value, cc('', jsonPrefix, '->', quoteStrings) + field) 181 | } 182 | // falls through 183 | default: 184 | strValue = this._valueToString(value, '{}', quoteStrings) 185 | } 186 | const jsonOperator = typeof value === 'string' ? '->>' : '->' 187 | return { 188 | key: cc('', jsonPrefix, jsonOperator) + field, 189 | value: [...ops, strValue].join('.') 190 | } 191 | } 192 | }).flat().filter(Boolean) 193 | } 194 | 195 | _valueToString (value, arrayBrackets, quoteStrings) { 196 | if (value === null) { 197 | return 'null' 198 | } else if (typeof value === 'boolean') { 199 | return value.toString() 200 | } else if (Array.isArray(value)) { 201 | return arrayBrackets.charAt(0) + value.map(v => this._valueToString(v, '{}', true)).join(',') + arrayBrackets.charAt(1) 202 | } else if (typeof value === 'object') { 203 | // range type 204 | const { lower, includeLower = true, upper, includeUpper = false } = value 205 | return (includeLower ? '[' : '(') + lower + ',' + upper + (includeUpper ? ']' : ')') 206 | } else { 207 | return quoteStrings ? quoteValue(value) : value 208 | } 209 | } 210 | } 211 | 212 | export default Query 213 | -------------------------------------------------------------------------------- /tests/unit/Schema.spec.js: -------------------------------------------------------------------------------- 1 | import Schema, { resetSchemaCache } from '@/Schema' 2 | import Route from '@/Route' 3 | import RPC from '@/RPC' 4 | import { SchemaNotFoundError } from '@/index' 5 | import ObservableFunction from '@/ObservableFunction' 6 | 7 | import request from '@/request' 8 | jest.mock('@/request') 9 | 10 | describe('Schema', () => { 11 | beforeEach(() => { 12 | resetSchemaCache() 13 | // just reset .mock data, but not .mockResponse 14 | fetch.mockClear() 15 | }) 16 | 17 | describe('ready method', () => { 18 | it('throws error if api does not exist', async () => { 19 | await expect((new Schema('/404')).$ready).rejects.toThrow('No openapi definition found for api-root: /404') 20 | await expect((new Schema('/404')).$ready).rejects.toThrow(SchemaNotFoundError) 21 | }) 22 | 23 | it('throws error if exists but is not json', async () => { 24 | await expect((new Schema('/text')).$ready).rejects.toThrow('No openapi definition found for api-root: /text') 25 | await expect((new Schema('/text')).$ready).rejects.toThrow(SchemaNotFoundError) 26 | }) 27 | 28 | it('throws error if exists but is regular json', async () => { 29 | await expect((new Schema('/json')).$ready).rejects.toThrow('No openapi definition found for api-root: /json') 30 | await expect((new Schema('/json')).$ready).rejects.toThrow(SchemaNotFoundError) 31 | }) 32 | 33 | it('does not throw for empty schema with correct header', async () => { 34 | await expect((new Schema('/empty')).$ready).resolves.toBeUndefined() 35 | }) 36 | }) 37 | 38 | describe('tokens', () => { 39 | it('has not sent auth header in request when no token provided', async () => { 40 | const schema = new Schema('/pk-api') 41 | await schema.$ready 42 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/pk-api', expect.objectContaining({ 43 | headers: new Headers() 44 | })) 45 | }) 46 | 47 | it('used api token in request', async () => { 48 | const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiamRvZSIsImV4cCI6MTQ3NTUxNjI1MH0.GYDZV3yM0gqvuEtJmfpplLBXSGYnke_Pvnl0tbKAjB' 49 | const schema = new Schema('/pk-api', token) 50 | await schema.$ready 51 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/pk-api', expect.objectContaining({ 52 | headers: new Headers({ 53 | Authorization: `Bearer ${token}` 54 | }) 55 | })) 56 | }) 57 | }) 58 | 59 | describe('cache', () => { 60 | it('returns cached schema when called twice', async () => { 61 | const schema = new Schema('/pk-api') 62 | await schema.$ready 63 | expect(fetch).toHaveBeenCalledTimes(1) 64 | const schemaCached = new Schema('/pk-api') 65 | await schemaCached.$ready 66 | expect(fetch).toHaveBeenCalledTimes(1) 67 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/pk-api', expect.anything()) 68 | expect(schemaCached).toBe(schema) 69 | }) 70 | 71 | it('separates cache by api-root', async () => { 72 | const schema = new Schema('/pk-api') 73 | await schema.$ready 74 | expect(fetch).toHaveBeenCalledTimes(1) 75 | const schema2 = new Schema('/pk-api2') 76 | await schema2.$ready 77 | expect(fetch).toHaveBeenCalledTimes(2) 78 | }) 79 | 80 | it('separates cache by token', async () => { 81 | const schema = new Schema('/pk-api') 82 | await schema.$ready 83 | expect(fetch).toHaveBeenCalledTimes(1) 84 | const schema2 = new Schema('/pk-api', 'token') 85 | await schema2.$ready 86 | expect(fetch).toHaveBeenCalledTimes(2) 87 | }) 88 | }) 89 | 90 | describe('called as function', () => { 91 | const schema = new Schema('/pk-api') 92 | beforeAll(() => schema.$ready) 93 | 94 | it('returns current schema instance without arguments', () => { 95 | // repeat this in here, because the schema cache should not be cleared to receive the same instance 96 | const schema = new Schema('/pk-api') 97 | const calledSchema = schema() 98 | expect(calledSchema).toBeInstanceOf(Schema) 99 | expect(schema).toBe(calledSchema) 100 | }) 101 | 102 | it('ready rejects for unavailable schema', async () => { 103 | const calledSchema = schema('/404') 104 | await expect(calledSchema.$ready).rejects.toThrow() 105 | }) 106 | 107 | it('ready resolves for available schema', async () => { 108 | const calledSchema = schema('/api') 109 | await calledSchema.$ready 110 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api', expect.anything()) 111 | expect(schema.clients).toBeUndefined() 112 | expect(calledSchema.clients.pks).toEqual(['id']) 113 | }) 114 | 115 | it('passes token', async () => { 116 | const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiamRvZSIsImV4cCI6MTQ3NTUxNjI1MH0.GYDZV3yM0gqvuEtJmfpplLBXSGYnke_Pvnl0tbKAjB' 117 | const calledSchema = schema('/api', token) 118 | await calledSchema.$ready 119 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api', expect.objectContaining({ 120 | headers: new Headers({ 121 | Authorization: `Bearer ${token}` 122 | }) 123 | })) 124 | }) 125 | 126 | it('re-uses default apiRoot when only token is passed', async () => { 127 | const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiamRvZSIsImV4cCI6MTQ3NTUxNjI1MH0.GYDZV3yM0gqvuEtJmfpplLBXSGYnke_Pvnl0tbKAjB' 128 | const calledSchema = schema(undefined, token) 129 | await calledSchema.$ready 130 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/pk-api', expect.objectContaining({ 131 | headers: new Headers({ 132 | Authorization: `Bearer ${token}` 133 | }) 134 | })) 135 | }) 136 | }) 137 | 138 | describe('$route()', () => { 139 | const schema = new Schema('/api') 140 | beforeAll(() => schema.$ready) 141 | 142 | it('returns route object', () => { 143 | const route = schema.$route('clients') 144 | expect(route).toBeInstanceOf(Route) 145 | }) 146 | 147 | it('route object has pks set', async () => { 148 | const route = schema.$route('clients') 149 | await route.$ready 150 | expect(route.pks).toEqual(['id']) 151 | }) 152 | 153 | it('route ready rejects for unavailable schema', async () => { 154 | const route = schema('/404').$route('clients') 155 | await expect(route.$ready).rejects.toThrow() 156 | }) 157 | 158 | it('provides curried functions as props', async () => { 159 | await schema.$ready 160 | expect(schema.clients).toBeInstanceOf(Route) 161 | schema.clients() 162 | expect(request).toHaveBeenCalledWith('/api', undefined, 'clients') 163 | 164 | expect(schema.other).toBeInstanceOf(Route) 165 | schema.other() 166 | expect(request).toHaveBeenCalledWith('/api', undefined, 'other') 167 | }) 168 | }) 169 | 170 | describe('RPC', () => { 171 | const schema = new Schema('/api') 172 | beforeAll(() => schema.$ready) 173 | 174 | it('schema has rpc function', () => { 175 | expect(schema.rpc).toBeInstanceOf(RPC) 176 | expect(schema.rpc).toBeInstanceOf(Function) 177 | }) 178 | 179 | it('curries request function with schema data', () => { 180 | schema.rpc('test') 181 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'rpc/test', 'POST', undefined, {}, undefined) 182 | const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiamRvZSIsImV4cCI6MTQ3NTUxNjI1MH0.GYDZV3yM0gqvuEtJmfpplLBXSGYnke_Pvnl0tbKAjB' 183 | const tokenSchema = schema('/pk-api', token) 184 | tokenSchema.rpc('test') 185 | expect(request).toHaveBeenLastCalledWith('/pk-api', token, 'rpc/test', 'POST', undefined, {}, undefined) 186 | }) 187 | 188 | it('provides curried functions as props', async () => { 189 | await schema.rpc.$ready 190 | schema.rpc.authenticate({ user: 'test' }, { query: { select: 'id' } }) 191 | expect(schema.rpc.authenticate).toBeInstanceOf(ObservableFunction) 192 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'rpc/authenticate', 'POST', { select: 'id' }, { signal: expect.any(AbortSignal) }, { user: 'test' }) 193 | }) 194 | }) 195 | }) 196 | -------------------------------------------------------------------------------- /docs/query/index.md: -------------------------------------------------------------------------------- 1 | # Query 2 | 3 | The `Query` class converts an input object into a PostgREST query string. 4 | 5 | By default, all values are converted to their appropriate representation for PostgREST query strings. 6 | 7 | ## Values & Operators 8 | 9 | ### Undefined 10 | 11 | Undefined values are excluded from the query string. 12 | 13 | 14 | 15 | ``` js 16 | const query = { 17 | 'age.lt': 13, 18 | 'grade.gte': undefined 19 | } 20 | ``` 21 | 22 | 23 | 24 | ### Arrays 25 | 26 | Arrays are parsed depending on the used operator. 27 | 28 | 29 | 30 | ``` js 31 | const query = { 32 | 'id.in': [1, 2, 3] 33 | } 34 | ``` 35 | 36 | 37 | 38 | 39 | 40 | ``` js 41 | const query = { 42 | 'tags.cs': ['example', 'new'] 43 | } 44 | ``` 45 | 46 | 47 | 48 | ### Range Objects 49 | 50 | 51 | 52 | ``` js 53 | const query = { 54 | 'range.sl': { lower: 1, upper: 10 } 55 | } 56 | ``` 57 | 58 | 59 | 60 | 61 | 62 | ``` js 63 | const query = { 64 | 'range.sl': { lower: 1, includeLower: false, upper: 10, includeUpper: true } 65 | } 66 | ``` 67 | 68 | 69 | 70 | ## Horizontal Filtering (Rows) 71 | 72 | ### Column Conditions 73 | 74 | 75 | 76 | ``` js 77 | const query = { 78 | 'age.lt': 13 79 | } 80 | ``` 81 | 82 | 83 | 84 | ### Logical Conjoining (AND) 85 | 86 | 87 | 88 | ``` js 89 | const query = { 90 | 'age.lt': 13, 91 | 'student.is': true 92 | } 93 | ``` 94 | 95 | 96 | 97 | 98 | 99 | ``` js 100 | const query = { 101 | 'grade.gte': 90, 102 | 'age.not.eq': 14 103 | } 104 | ``` 105 | 106 | 107 | 108 | ### Logical Disjoining (OR) 109 | 110 | 111 | 112 | ``` js 113 | const query = { 114 | or: { 115 | 'grade.gte': 20, 116 | 'age.lte': 30, 117 | } 118 | } 119 | ``` 120 | 121 | 122 | 123 | For setting two conditions on the same column, use aliases - these are stripped before creating the query string. 124 | 125 | 126 | 127 | ``` js 128 | const query = { 129 | or: { 130 | '0:grade.eq': 20, 131 | '1:grade.eq': 50, 132 | } 133 | } 134 | ``` 135 | 136 | 137 | 138 | Negated logical operators and nesting: 139 | 140 | 141 | 142 | ``` js 143 | const query = { 144 | and: { 145 | 'grade.gte': 90, 146 | 'student.is': true, 147 | 'not.or': { 148 | 'age.eq': 14, 149 | 'age.not.is': null 150 | } 151 | } 152 | } 153 | ``` 154 | 155 | 156 | 157 | ### Full-Text Search 158 | 159 | 160 | 161 | ``` js 162 | const query = { 163 | 'my_tsv.fts(french)': 'amusant' 164 | } 165 | ``` 166 | 167 | 168 | 169 | ## Vertical Filtering (Columns) 170 | 171 | ### Selecting 172 | 173 | 174 | 175 | ``` js 176 | const query = { 177 | select: '*' 178 | } 179 | ``` 180 | 181 | 182 | 183 | 184 | 185 | ``` js 186 | const query = { 187 | select: 'first_name,age' 188 | } 189 | ``` 190 | 191 | 192 | 193 | 194 | 195 | ``` js 196 | const query = { 197 | select: ['first_name', 'age'] 198 | } 199 | ``` 200 | 201 | 202 | 203 | 204 | 205 | ``` js 206 | const query = { 207 | select: { 208 | 'first_name': true, 209 | // NOTE: falsy values are ignored! 210 | age: 0 211 | } 212 | } 213 | ``` 214 | 215 | 216 | 217 | ### Renaming Columns 218 | 219 | 220 | 221 | ``` js 222 | const query = { 223 | select: ['firstName:first_name', 'age'] 224 | } 225 | ``` 226 | 227 | 228 | 229 | 230 | 231 | ``` js 232 | const query = { 233 | select: { 234 | 'firstName:first_name': true 235 | } 236 | } 237 | ``` 238 | 239 | 240 | 241 | ### Casting Columns 242 | 243 | 244 | 245 | ``` js 246 | const query = { 247 | select: ['full_name', 'salary::text'] 248 | } 249 | ``` 250 | 251 | 252 | 253 | 254 | 255 | ``` js 256 | const query = { 257 | select: { 258 | 'full_name': true, 259 | salary: 'text' 260 | } 261 | } 262 | ``` 263 | 264 | 265 | 266 | 267 | 268 | ``` js 269 | const query = { 270 | select: { 271 | 'full_name': true, 272 | salary: { 273 | '::': 'text' 274 | } 275 | } 276 | } 277 | ``` 278 | 279 | 280 | 281 | ### JSON Columns 282 | 283 | 284 | 285 | ``` js 286 | const query = { 287 | select: ['id', 'json_data->>blood_type', 'json_data->phones'] 288 | } 289 | ``` 290 | 291 | 292 | 293 | ::: tip 294 | If a field of the `select` object has a `select` key itself, it is handled as an embed, otherwise as a JSON field. 295 | ::: 296 | 297 | 298 | 299 | ``` js 300 | const query = { 301 | select: { 302 | id: true, 303 | json_data: { 304 | blood_type: true, 305 | phones: true 306 | } 307 | }, 308 | // Nested filter on JSON column 309 | json_data: { 310 | 'age.gt': 20 311 | } 312 | } 313 | ``` 314 | 315 | 316 | 317 | 318 | 319 | ``` js 320 | const query = { 321 | select: { 322 | id: true, 323 | json_data: { 324 | phones: [ 325 | { 326 | number: true 327 | } 328 | ] 329 | } 330 | } 331 | } 332 | ``` 333 | 334 | 335 | 336 | If a JSON column is aliased or cast in object syntax, the json_data field is added to the query string. E.g.: 337 | 338 | 339 | 340 | ``` js 341 | const query = { 342 | select: { 343 | id: true, 344 | 'jd:json_data': { 345 | blood_type: true, 346 | phones: true 347 | } 348 | } 349 | } 350 | ``` 351 | 352 | 353 | 354 | 355 | 356 | ``` js 357 | const query = { 358 | select: { 359 | id: true, 360 | json_data: { 361 | '::': 'json', 362 | blood_type: true, 363 | phones: true 364 | } 365 | } 366 | } 367 | ``` 368 | 369 | 370 | 371 | ## Ordering 372 | 373 | 374 | 375 | ``` js 376 | const query = { 377 | order: 'age.desc,height.asc' 378 | } 379 | ``` 380 | 381 | 382 | 383 | 384 | 385 | ``` js 386 | const query = { 387 | order: ['age.desc', 'height.asc'] 388 | } 389 | ``` 390 | 391 | 392 | 393 | 394 | 395 | ``` js 396 | const query = { 397 | order: [ 398 | ['age', 'desc'], 399 | ['height', 'asc'] 400 | ] 401 | } 402 | ``` 403 | 404 | 405 | 406 | 407 | 408 | ``` js 409 | const query = { 410 | order: { 411 | age: 'desc', 412 | height: 'asc.nullslast' 413 | } 414 | } 415 | ``` 416 | 417 | 418 | 419 | 420 | 421 | ``` js 422 | const query = { 423 | order: { 424 | age: true 425 | } 426 | } 427 | ``` 428 | 429 | 430 | 431 | ## Limits and Pagination 432 | 433 | 434 | 435 | ``` js 436 | const query = { 437 | limit: 1, 438 | offset: 10 439 | } 440 | ``` 441 | 442 | 443 | 444 | ## Resource Embedding 445 | 446 | ::: tip 447 | If a field of the `select` object has a `select` key itself, it is handled as an embed, otherwise as a JSON field. 448 | ::: 449 | 450 | **Simple** 451 | 452 | 453 | 454 | ``` js 455 | const query = { 456 | select: { 457 | title: true, 458 | directors: { 459 | select: { 460 | id: true, 461 | last_name: true 462 | } 463 | } 464 | } 465 | } 466 | ``` 467 | 468 | 469 | 470 | **Aliases** 471 | 472 | 473 | 474 | ``` js 475 | const query = { 476 | select: { 477 | title: true, 478 | 'director:directors': { 479 | select: { 480 | id: true, 481 | last_name: true 482 | } 483 | } 484 | } 485 | } 486 | ``` 487 | 488 | 489 | 490 | **Full Example** 491 | 492 | 493 | 494 | ``` js 495 | const query = { 496 | select: { 497 | '*': true, 498 | actors: { 499 | select: '*', 500 | order: ['last_name', 'first_name'], 501 | 'character.in': ['Chico', 'Harpo', 'Groucho'], 502 | limit: 10, 503 | offset: 2 504 | }, 505 | '91_comps:competitions': { 506 | select: 'name' 507 | }, 508 | 'central_addresses!billing_address': { 509 | select: '*' 510 | } 511 | }, 512 | '91_comps.year.eq': 1991 513 | } 514 | ``` 515 | 516 | 517 | 518 | ## Insertions / Updates 519 | 520 | ### Columns 521 | 522 | 523 | 524 | ``` js 525 | const query = { 526 | columns: 'source,publication_date,figure' 527 | } 528 | ``` 529 | 530 | 531 | 532 | 533 | 534 | ``` js 535 | const query = { 536 | columns: ['source', 'publication_date', 'figure'] 537 | } 538 | ``` 539 | 540 | 541 | 542 | ### On Conflict 543 | 544 | 545 | 546 | ``` js 547 | const query = { 548 | on_conflict: 'source' 549 | } 550 | ``` 551 | 552 | 553 | 554 | 555 | 556 | ``` js 557 | const query = { 558 | on_conflict: ['source', 'publication_date', 'figure'] 559 | } 560 | ``` 561 | 562 | 563 | -------------------------------------------------------------------------------- /tests/unit/GenericCollection.spec.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils' 2 | import GenericCollection from '@/GenericCollection' 3 | import GenericModel from '@/GenericModel' 4 | import ObservableFunction from '@/ObservableFunction' 5 | import Schema from '@/Schema' 6 | 7 | // mock request function with actual call included (spy) 8 | import request from '@/request' 9 | jest.mock('@/request', () => { 10 | const { default: req } = jest.requireActual('@/request') 11 | return { 12 | __esModule: true, 13 | default: jest.fn(req) 14 | } 15 | }) 16 | 17 | const data = [{ 18 | id: 1, 19 | name: 'A' 20 | }, { 21 | id: 2, 22 | name: 'B' 23 | }] 24 | 25 | const mockReturn = [{ 26 | id: 3, 27 | name: 'C' 28 | }, { 29 | id: 4, 30 | name: 'D' 31 | }] 32 | 33 | describe('GenericCollection', () => { 34 | const schema = new Schema('/api') 35 | const route = schema.$route('clients') 36 | beforeAll(async () => { 37 | await route.$ready 38 | }) 39 | beforeEach(() => { 40 | request.mockClear() 41 | }) 42 | 43 | it('extends Array', () => { 44 | const collection = new GenericCollection({ route }) 45 | expect(Array.isArray(collection)).toBe(true) 46 | }) 47 | 48 | it('has GenericCollection prototype', () => { 49 | const collection = new GenericCollection({ route }) 50 | expect(collection).toBeInstanceOf(GenericCollection) 51 | }) 52 | 53 | it('throws when passing non objects to the constructor', () => { /* eslint-disable no-new */ 54 | expect.assertions(2) 55 | try { 56 | new GenericCollection({ route }, 'wrong') 57 | } catch (e) { 58 | expect(e).toBeInstanceOf(Error) 59 | } 60 | try { 61 | new GenericCollection({ route }, null) 62 | } catch (e) { 63 | expect(e).toBeInstanceOf(Error) 64 | } 65 | }) 66 | 67 | it('holds instances of GenericModel', () => { 68 | expect.assertions(2) 69 | const collection = new GenericCollection({ route }, ...data) 70 | 71 | collection.forEach(m => expect(m).toBeInstanceOf(GenericModel)) 72 | }) 73 | 74 | it('throws when adding non objects to the array', () => { 75 | expect.assertions(2) 76 | const collection = new GenericCollection({ route }) 77 | try { 78 | collection[0] = 'wrong' 79 | } catch (e) { 80 | expect(e).toBeInstanceOf(Error) 81 | } 82 | try { 83 | collection.push(null) 84 | } catch (e) { 85 | expect(e).toBeInstanceOf(Error) 86 | } 87 | }) 88 | 89 | it('turns objects into GenericModels when manipulating', () => { 90 | expect.assertions(2) 91 | const collection = new GenericCollection({ route }) 92 | collection[0] = data[0] 93 | collection.push(data[1]) 94 | collection.forEach(m => expect(m).toBeInstanceOf(GenericModel)) 95 | }) 96 | 97 | it('creates GenericModels with proper route, query and select parameters', async () => { 98 | const collection = new GenericCollection({ route, query: { select: 'id' } }, ...data) 99 | await collection[0].$get() 100 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', { 'id.eq': 1, select: 'id' }, { accept: 'single', signal: expect.any(AbortSignal) }) 101 | }) 102 | 103 | it('allows delete on the array items', () => { 104 | const collection = new GenericCollection({ route }, ...data) 105 | expect(collection[1]).not.toBeUndefined() 106 | delete collection[1] 107 | expect(collection[1]).toBeUndefined() 108 | }) 109 | 110 | it('returns regular Array on .map(..)', () => { 111 | const collection = new GenericCollection({ route }, ...data) 112 | const ids = collection.map(model => model.id) 113 | expect(ids).not.toBeInstanceOf(GenericCollection) 114 | expect(Array.isArray(ids)).toBe(true) 115 | expect(ids).toEqual([1, 2]) 116 | }) 117 | 118 | describe('Vue reacts', () => { 119 | const Component = { 120 | render () {}, 121 | data: () => ({ collection: null }), 122 | mounted () { 123 | this.collection = new GenericCollection({ route }) 124 | } 125 | } 126 | let wrapper 127 | 128 | beforeEach(() => { 129 | wrapper = shallowMount(Component) 130 | }) 131 | afterEach(() => wrapper.destroy()) 132 | 133 | it('to added items via push', () => { 134 | expect.assertions(1) 135 | wrapper.vm.$watch('collection', collection => { 136 | expect(collection.length).toBe(1) 137 | }) 138 | wrapper.vm.collection.push({}) 139 | }) 140 | 141 | it('to added items via set', () => { 142 | expect.assertions(1) 143 | wrapper.vm.$watch('collection', collection => { 144 | expect(collection.length).toBe(1) 145 | }) 146 | wrapper.vm.$set(wrapper.vm.collection, 0, {}) 147 | }) 148 | 149 | it('to added items via $new', () => { 150 | expect.assertions(1) 151 | wrapper.vm.$watch('collection', collection => { 152 | expect(collection.length).toBe(1) 153 | }) 154 | wrapper.vm.collection.$new({}) 155 | }) 156 | 157 | it('to added items via $get', () => { 158 | expect.assertions(1) 159 | wrapper.vm.$watch('collection', collection => { 160 | expect(collection.length).toBe(2) 161 | }) 162 | request.mockReturnValueOnce({ 163 | json: async () => mockReturn, 164 | headers: new Headers() 165 | }) 166 | return wrapper.vm.collection.$get() 167 | }) 168 | }) 169 | 170 | describe('Get method', () => { 171 | it('has observable method "$get"', () => { 172 | const collection = new GenericCollection() 173 | expect(collection.$get).toBeInstanceOf(ObservableFunction) 174 | }) 175 | 176 | it('property "$get" is from prototype and not configurable, writable or deletable', () => { 177 | const collection = new GenericCollection({ route, query: { 'id.gt': 1 } }) 178 | expect(Reflect.getOwnPropertyDescriptor(collection, '$get')).toBeUndefined() 179 | expect('$get' in collection).toBe(true) 180 | expect(() => { 181 | collection.$get = 'writable' 182 | }).toThrow() 183 | expect(() => { 184 | Object.defineProperty(collection, '$get', { value: 'configurable' }) 185 | }).toThrow() 186 | expect(() => { 187 | delete collection.$get 188 | }).toThrow() 189 | }) 190 | 191 | it('sends a get request with query', async () => { 192 | const collection = new GenericCollection({ route, query: { 'id.gt': 1 } }) 193 | await collection.$get() 194 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', { 'id.gt': 1 }, { signal: expect.any(AbortSignal) }) 195 | }) 196 | 197 | it('passes options except accept', async () => { 198 | const collection = new GenericCollection({ route, query: {} }) 199 | const options = { 200 | headers: { prefer: 'custom-prefer-header', accept: 'custom-accept-header', 'x-header': 'custom-x-header' }, 201 | accept: 'single' 202 | } 203 | await collection.$get(options) 204 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { headers: options.headers, signal: expect.any(AbortSignal) }) 205 | }) 206 | 207 | it('returns the request\'s return value and updates collection data', async () => { 208 | request.mockReturnValueOnce({ 209 | json: async () => mockReturn, 210 | headers: new Headers() 211 | }) 212 | const collection = new GenericCollection({ route }, ...data) 213 | expect(collection).toMatchObject(data) 214 | const ret = await collection.$get() 215 | expect(ret).toEqual(mockReturn) 216 | expect(collection).toMatchObject(mockReturn) 217 | }) 218 | 219 | it('passes options limit, offset and count', async () => { 220 | const collection = new GenericCollection({ 221 | route, 222 | limit: 5, 223 | offset: 10, 224 | count: 'exact' 225 | }) 226 | await collection.$get() 227 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { 228 | limit: 5, 229 | offset: 10, 230 | count: 'exact', 231 | signal: expect.any(AbortSignal) 232 | }) 233 | }) 234 | }) 235 | 236 | describe('$range object', () => { 237 | it('exists when header set #1', async () => { 238 | const collection = new GenericCollection({ 239 | route, 240 | limit: 2 241 | }) 242 | await collection.$get() 243 | expect(collection.$range).toMatchObject({ 244 | totalCount: undefined, 245 | first: 0, 246 | last: 1 247 | }) 248 | }) 249 | 250 | it('exists when header set #2', async () => { 251 | const collection = new GenericCollection({ 252 | route, 253 | count: 'exact' 254 | }) 255 | await collection.$get() 256 | expect(collection.$range).toMatchObject({ 257 | totalCount: 3, 258 | first: 0, 259 | last: undefined 260 | }) 261 | }) 262 | }) 263 | 264 | describe('New method', () => { 265 | it('has method "$new"', () => { 266 | const collection = new GenericCollection() 267 | expect(collection.$new).toBeInstanceOf(Function) 268 | }) 269 | 270 | it('property "$new" is from prototype and not configurable, writable or deletable', () => { 271 | const collection = new GenericCollection({ route, query: { 'id.gt': 1 } }) 272 | expect(Reflect.getOwnPropertyDescriptor(collection, '$new')).toBeUndefined() 273 | expect('$new' in collection).toBe(true) 274 | expect(() => { 275 | collection.$new = 'writable' 276 | }).toThrow() 277 | expect(() => { 278 | Object.defineProperty(collection, '$new', { value: 'configurable' }) 279 | }).toThrow() 280 | expect(() => { 281 | delete collection.$new 282 | }).toThrow() 283 | }) 284 | 285 | it('creates and returns GenericModel added to the array', () => { 286 | const collection = new GenericCollection({ route }, data[0]) 287 | const model = collection.$new(data[1]) 288 | expect(model).toBeInstanceOf(GenericModel) 289 | expect(model).toMatchObject(data[1]) 290 | expect(collection[1]).toBe(model) 291 | }) 292 | 293 | it('can delete "$new" records after they "$post"', async () => { 294 | request.mockReturnValue({ 295 | json: async () => ({ 296 | name: 'client 2', 297 | id: 2 298 | }), 299 | headers: new Headers() 300 | }) 301 | 302 | const collection = new GenericCollection({ route, query: { select: { id: true, name: true } } }) 303 | await collection.$new({ name: 'client 2' }) 304 | 305 | const model = collection.at(-1) 306 | await model.$post() 307 | request.mockClear() 308 | 309 | expect(model.id).toBe(2) 310 | expect(model.name).toBe('client 2') 311 | 312 | await model.$delete() 313 | 314 | expect(request).toHaveBeenCalledTimes(1) 315 | expect(request).toHaveBeenCalledWith('/api', undefined, 'clients', 'DELETE', 316 | { 317 | 'id.eq': 2, 318 | select: { id: true, name: true } 319 | }, 320 | { 321 | accept: 'single', 322 | signal: expect.any(AbortSignal) 323 | }) 324 | }) 325 | }) 326 | }) 327 | -------------------------------------------------------------------------------- /tests/unit/ObservableFunction.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import ObservableFunction from '@/ObservableFunction' 3 | 4 | describe('ObservableFunction', () => { 5 | let fn 6 | beforeEach(() => { 7 | fn = new ObservableFunction((signal, id) => id) 8 | }) 9 | 10 | it('is callable', () => { 11 | expect(typeof fn).toBe('function') 12 | expect(fn).not.toThrow() 13 | }) 14 | 15 | describe('returns promise', () => { 16 | it('resolving with function return', async () => { 17 | await expect(fn(1)).resolves.toBe(1) 18 | await expect(fn(2)).resolves.toBe(2) 19 | }) 20 | 21 | it('rejects with thrown error', async () => { 22 | const fn = new ObservableFunction(() => { throw new Error('test') }) 23 | await expect(fn()).rejects.toThrow('test') 24 | }) 25 | }) 26 | 27 | describe('hasReturned', () => { 28 | it('has prop with default false', () => { 29 | expect(fn.hasReturned).toBe(false) 30 | }) 31 | 32 | it('is still false while first request is pending', async () => { 33 | expect.assertions(2) 34 | expect(fn.hasReturned).toBe(false) 35 | const p = fn(new Promise((resolve) => resolve())) 36 | expect(fn.hasReturned).toBe(false) 37 | await p 38 | }) 39 | 40 | it('is still false when first request is rejected', async () => { 41 | expect.assertions(3) 42 | expect(fn.hasReturned).toBe(false) 43 | const p = fn(new Promise((resolve, reject) => reject(new Error()))) 44 | expect(fn.hasReturned).toBe(false) 45 | try { 46 | await p 47 | } catch { 48 | expect(fn.hasReturned).toBe(false) 49 | } 50 | }) 51 | 52 | it('is true when first request is resolved', async () => { 53 | expect.assertions(3) 54 | expect(fn.hasReturned).toBe(false) 55 | const p = fn(new Promise((resolve) => resolve())) 56 | expect(fn.hasReturned).toBe(false) 57 | await p 58 | expect(fn.hasReturned).toBe(true) 59 | }) 60 | 61 | it('is still true when second request is pending', async () => { 62 | expect.assertions(4) 63 | expect(fn.hasReturned).toBe(false) 64 | const p1 = fn(new Promise((resolve) => resolve())) 65 | expect(fn.hasReturned).toBe(false) 66 | await p1 67 | expect(fn.hasReturned).toBe(true) 68 | const p2 = fn(new Promise((resolve) => resolve())) 69 | expect(fn.hasReturned).toBe(true) 70 | await p2 71 | }) 72 | 73 | it('is still true when second request is resolved', async () => { 74 | expect.assertions(5) 75 | expect(fn.hasReturned).toBe(false) 76 | const p1 = fn(new Promise((resolve) => resolve())) 77 | expect(fn.hasReturned).toBe(false) 78 | await p1 79 | expect(fn.hasReturned).toBe(true) 80 | const p2 = fn(new Promise((resolve) => resolve())) 81 | expect(fn.hasReturned).toBe(true) 82 | await p2 83 | expect(fn.hasReturned).toBe(true) 84 | }) 85 | 86 | it('is still true when second request is rejected', async () => { 87 | expect.assertions(5) 88 | expect(fn.hasReturned).toBe(false) 89 | const p1 = fn(new Promise((resolve) => resolve())) 90 | expect(fn.hasReturned).toBe(false) 91 | await p1 92 | expect(fn.hasReturned).toBe(true) 93 | const p2 = fn(new Promise((resolve, reject) => reject(new Error()))) 94 | expect(fn.hasReturned).toBe(true) 95 | try { 96 | await p2 97 | } catch { 98 | expect(fn.hasReturned).toBe(true) 99 | } 100 | }) 101 | }) 102 | 103 | describe('pending', () => { 104 | it('has prop with default empty array', () => { 105 | expect(fn.pending).toMatchObject([]) 106 | }) 107 | 108 | it('is Array of AbortControllers while async function call is pending', async () => { 109 | expect.assertions(3) 110 | expect(fn.pending).toMatchObject([]) 111 | const p = fn(new Promise((resolve) => resolve())) 112 | expect(fn.pending).toMatchObject([ 113 | expect.any(AbortController) 114 | ]) 115 | await p 116 | expect(fn.pending).toMatchObject([]) 117 | }) 118 | 119 | it('is empty Array when async function call is rejected', async () => { 120 | expect.assertions(3) 121 | expect(fn.pending).toMatchObject([]) 122 | const p = fn(new Promise((resolve, reject) => reject(new Error()))) 123 | expect(fn.pending).toMatchObject([ 124 | expect.any(AbortController) 125 | ]) 126 | try { 127 | await p 128 | } catch { 129 | expect(fn.pending).toMatchObject([]) 130 | } 131 | }) 132 | }) 133 | 134 | describe('isPending', () => { 135 | it('has prop with default false', () => { 136 | expect(fn.isPending).toBe(false) 137 | }) 138 | 139 | it('is true while async function call is pending', async () => { 140 | expect.assertions(3) 141 | expect(fn.isPending).toBe(false) 142 | const p = fn(new Promise((resolve) => resolve())) 143 | expect(fn.isPending).toBe(true) 144 | await p 145 | expect(fn.isPending).toBe(false) 146 | }) 147 | 148 | it('is false when async function is rejected', async () => { 149 | expect.assertions(3) 150 | expect(fn.isPending).toBe(false) 151 | const p = fn(new Promise((resolve, reject) => reject(new Error()))) 152 | expect(fn.isPending).toBe(true) 153 | try { 154 | await p 155 | } catch { 156 | expect(fn.isPending).toBe(false) 157 | } 158 | }) 159 | }) 160 | 161 | describe('error handling', () => { 162 | it('has "hasError" prop with default false', () => { 163 | expect(fn.hasError).toBe(false) 164 | }) 165 | 166 | it('"hasError" is false when async function is resolved', async () => { 167 | expect.assertions(3) 168 | expect(fn.hasError).toBe(false) 169 | const p = fn(new Promise((resolve) => resolve())) 170 | expect(fn.hasError).toBe(false) 171 | await p 172 | expect(fn.hasError).toBe(false) 173 | }) 174 | 175 | it('"hasError" is true when async function is rejected', async () => { 176 | expect.assertions(4) 177 | expect(fn.hasError).toBe(false) 178 | const p = fn(new Promise((resolve, reject) => reject(new Error('test')))) 179 | expect(fn.hasError).toBe(false) 180 | try { 181 | await p 182 | } catch { 183 | expect(fn.hasError).toBe(true) 184 | expect(fn.errors).toEqual([Error('test')]) 185 | } 186 | }) 187 | 188 | it('"errors" is array of multiple rejections', async () => { 189 | expect.assertions(3) 190 | expect(fn.errors).toEqual([]) 191 | const p1 = fn(new Promise((resolve, reject) => reject(new Error('test')))) 192 | const p2 = fn(new Promise((resolve, reject) => reject(new Error('test2')))) 193 | expect(fn.errors).toEqual([]) 194 | try { 195 | await p1 196 | } catch { 197 | try { 198 | await p2 199 | } catch { 200 | expect(fn.errors).toEqual([Error('test'), Error('test2')]) 201 | } 202 | } 203 | }) 204 | 205 | it('errors are cleared after one resolved call', async () => { 206 | expect.assertions(8) 207 | expect(fn.errors).toEqual([]) 208 | expect(fn.hasError).toBe(false) 209 | const p1 = fn(new Promise((resolve, reject) => reject(new Error('test')))) 210 | const p2 = fn(new Promise((resolve, reject) => reject(new Error('test2')))) 211 | expect(fn.errors).toEqual([]) 212 | expect(fn.hasError).toBe(false) 213 | try { 214 | await p1 215 | } catch { 216 | try { 217 | await p2 218 | } catch { 219 | expect(fn.errors).toEqual([Error('test'), Error('test2')]) 220 | expect(fn.hasError).toBe(true) 221 | } 222 | } 223 | const p3 = fn(new Promise((resolve) => resolve())) 224 | await p3 225 | expect(fn.errors).toEqual([]) 226 | expect(fn.hasError).toBe(false) 227 | }) 228 | }) 229 | 230 | describe('reactivity', () => { 231 | it('props are reactive', async () => { 232 | expect.assertions(3) 233 | const vueInstance = new Vue({ data: () => ({ fn }) }) 234 | const unwatchIsPending = vueInstance.$watch('fn.isPending', (isPending) => { 235 | expect(isPending).toBe(true) 236 | unwatchIsPending() 237 | }) 238 | const unwatchHasError = vueInstance.$watch('fn.hasError', (hasError) => { 239 | expect(hasError).toBe(true) 240 | unwatchHasError() 241 | }) 242 | const unwatchErrors = vueInstance.$watch('fn.errors', (errors) => { 243 | expect(errors).toEqual([Error('test')]) 244 | unwatchErrors() 245 | }) 246 | const p = fn(new Promise((resolve, reject) => reject(new Error('test')))) 247 | try { 248 | await p 249 | } catch {} 250 | }) 251 | }) 252 | 253 | describe('clear', () => { 254 | it('without argument clears .errors', async () => { 255 | expect.assertions(2) 256 | const p = fn(new Promise((resolve, reject) => reject(new Error('test')))) 257 | try { 258 | await p 259 | } catch {} 260 | const p2 = fn(new Promise((resolve, reject) => reject(new Error('test2')))) 261 | try { 262 | await p2 263 | } catch { 264 | expect(fn.errors).toEqual([Error('test'), Error('test2')]) 265 | fn.clear() 266 | expect(fn.errors).toEqual([]) 267 | } 268 | }) 269 | 270 | it('with error argument removes argument from .errors', async () => { 271 | expect.assertions(2) 272 | const p = fn(new Promise((resolve, reject) => reject(new Error('test')))) 273 | try { 274 | await p 275 | } catch {} 276 | const p2 = fn(new Promise((resolve, reject) => reject(new Error('test2')))) 277 | try { 278 | await p2 279 | } catch (e) { 280 | expect(fn.errors).toEqual([Error('test'), Error('test2')]) 281 | fn.clear(e) 282 | expect(fn.errors).toEqual([Error('test')]) 283 | } 284 | }) 285 | 286 | it('with int argument removes argument with index from .errors', async () => { 287 | expect.assertions(2) 288 | const p = fn(new Promise((resolve, reject) => reject(new Error('test')))) 289 | try { 290 | await p 291 | } catch {} 292 | const p2 = fn(new Promise((resolve, reject) => reject(new Error('test2')))) 293 | try { 294 | await p2 295 | } catch {} 296 | expect(fn.errors).toEqual([Error('test'), Error('test2')]) 297 | fn.clear(0) 298 | expect(fn.errors).toEqual([Error('test2')]) 299 | }) 300 | 301 | it('clears hasPeturned only without arguments', async () => { 302 | expect.assertions(4) 303 | 304 | const ret = fn(Promise.resolve()) 305 | await ret 306 | expect(fn.hasReturned).toBe(true) 307 | 308 | const fail1 = fn(Promise.reject(new Error('test1'))) 309 | try { 310 | await fail1 311 | } catch {} 312 | 313 | fn.clear(0) 314 | 315 | expect(fn.hasReturned).toBe(true) 316 | 317 | const fail2 = fn(Promise.reject(new Error('test2'))) 318 | try { 319 | await fail2 320 | } catch (e) { 321 | fn.clear(e) 322 | } 323 | 324 | expect(fn.hasReturned).toBe(true) 325 | 326 | fn.clear() 327 | 328 | expect(fn.hasReturned).toBe(false) 329 | }) 330 | }) 331 | }) 332 | -------------------------------------------------------------------------------- /tests/unit/mixin.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { shallowMount } from '@vue/test-utils' 3 | import flushPromises from 'flush-promises' 4 | import Postgrest, { pg } from '@/index' 5 | import GenericCollection from '@/GenericCollection' 6 | import GenericModel from '@/GenericModel' 7 | 8 | // mock request function with actual call included (spy) 9 | import request from '@/request' 10 | jest.mock('@/request', () => { 11 | const { default: req } = jest.requireActual('@/request') 12 | return { 13 | __esModule: true, 14 | default: jest.fn(req), 15 | setDefaultHeaders: jest.fn() 16 | } 17 | }) 18 | 19 | Vue.use(Postgrest, { 20 | apiRoot: '/api' 21 | }) 22 | 23 | describe('Mixin', () => { 24 | const Component = { 25 | render () {}, 26 | mixins: [pg], 27 | props: { 28 | single: { 29 | type: Boolean, 30 | default: false 31 | } 32 | }, 33 | data () { 34 | return { pgConfig: { route: 'clients', single: this.single } } 35 | } 36 | } 37 | let wrapper 38 | 39 | beforeEach(request.mockClear) 40 | afterEach(() => wrapper.destroy()) 41 | 42 | describe('with pgConfig unset initially', () => { 43 | beforeEach(() => { 44 | wrapper = shallowMount({ 45 | render () {}, 46 | mixins: [pg], 47 | data () { 48 | return { pgConfig: undefined } 49 | } 50 | }) 51 | }) 52 | 53 | it('should not make a request', async () => { 54 | await flushPromises() 55 | expect(request).not.toHaveBeenCalled() 56 | }) 57 | 58 | it('should enable GenericCollection when pgConfig is set', async () => { 59 | await flushPromises() 60 | wrapper.vm.pgConfig = { route: 'clients' } 61 | await flushPromises() 62 | expect(wrapper.vm.pg).toBeInstanceOf(GenericCollection) 63 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { signal: expect.any(AbortSignal) }) 64 | }) 65 | }) 66 | 67 | describe('with pgConfig.single = true', () => { 68 | beforeEach(() => { 69 | wrapper = shallowMount(Component, { propsData: { single: true } }) 70 | }) 71 | 72 | it('provides pg of type GenericModel', () => { 73 | expect(wrapper.vm.pg).toBeInstanceOf(GenericModel) 74 | }) 75 | 76 | it('pg does not make a call without query', async () => { 77 | await flushPromises() 78 | expect(request).not.toHaveBeenCalled() 79 | }) 80 | 81 | it('pg has proper route, query and select parameters', async () => { 82 | wrapper.vm.$set(wrapper.vm.pgConfig, 'query', { 83 | 'id.eq': 1, 84 | select: 'id' 85 | }) 86 | await flushPromises() 87 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', { 'id.eq': 1, select: 'id' }, { accept: 'single', signal: expect.any(AbortSignal) }) 88 | }) 89 | 90 | it('calls $get when pgConfig.query changed deep', async () => { 91 | wrapper.vm.$set(wrapper.vm.pgConfig, 'query', { 92 | and: { 93 | 'id.eq': 1 94 | } 95 | }) 96 | await flushPromises() 97 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', { and: { 'id.eq': 1 } }, { 98 | accept: 'single', 99 | signal: expect.any(AbortSignal) 100 | }) 101 | request.mockClear() 102 | wrapper.vm.$set(wrapper.vm.pgConfig.query.and, 'id.eq', 2) 103 | await flushPromises() 104 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', { and: { 'id.eq': 2 } }, { accept: 'single', signal: expect.any(AbortSignal) }) 105 | }) 106 | 107 | it('calls $get when pgConfig.apiRoot changed', async () => { 108 | wrapper.vm.$set(wrapper.vm.pgConfig, 'query', {}) 109 | await flushPromises() 110 | request.mockClear() 111 | wrapper.vm.$set(wrapper.vm.pgConfig, 'apiRoot', '/pk-api') 112 | await flushPromises() 113 | expect(request).toHaveBeenLastCalledWith('/pk-api', undefined, 'clients', 'GET', {}, { accept: 'single', signal: expect.any(AbortSignal) }) 114 | wrapper.vm.$set(wrapper.vm.pgConfig, 'apiRoot', '/api') 115 | await flushPromises() 116 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { accept: 'single', signal: expect.any(AbortSignal) }) 117 | }) 118 | 119 | it('calls $get when pgConfig.token changed', async () => { 120 | wrapper.vm.$set(wrapper.vm.pgConfig, 'query', {}) 121 | await flushPromises() 122 | request.mockClear() 123 | wrapper.vm.$set(wrapper.vm.pgConfig, 'token', 'test') 124 | await flushPromises() 125 | expect(request).toHaveBeenLastCalledWith('/api', 'test', 'clients', 'GET', {}, { accept: 'single', signal: expect.any(AbortSignal) }) 126 | wrapper.vm.$set(wrapper.vm.pgConfig, 'token', undefined) 127 | await flushPromises() 128 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { accept: 'single', signal: expect.any(AbortSignal) }) 129 | }) 130 | 131 | it('calls $get when pgConfig.route changed', async () => { 132 | wrapper.vm.$set(wrapper.vm.pgConfig, 'query', {}) 133 | await flushPromises() 134 | request.mockClear() 135 | wrapper.vm.$set(wrapper.vm.pgConfig, 'route', 'test') 136 | await flushPromises() 137 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'test', 'GET', {}, { accept: 'single', signal: expect.any(AbortSignal) }) 138 | }) 139 | }) 140 | 141 | describe('with pgConfig.single = false', () => { 142 | beforeEach(() => { 143 | wrapper = shallowMount(Component) 144 | }) 145 | 146 | it('provides pg of type GenericCollection', () => { 147 | expect(wrapper.vm.pg).toBeInstanceOf(GenericCollection) 148 | }) 149 | 150 | it('pg does make a call without query', async () => { 151 | await flushPromises() 152 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { signal: expect.any(AbortSignal) }) 153 | }) 154 | 155 | it('pg has proper route and query parameters', async () => { 156 | wrapper.vm.$set(wrapper.vm.pgConfig, 'query', { 157 | 'id.gt': 1, 158 | select: 'id' 159 | }) 160 | await flushPromises() 161 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', { 'id.gt': 1, select: 'id' }, { signal: expect.any(AbortSignal) }) 162 | }) 163 | 164 | it('calls $get when pgConfig.query changed deep', async () => { 165 | wrapper.vm.$set(wrapper.vm.pgConfig, 'query', { 166 | or: { 167 | 'id.eq': 1 168 | } 169 | }) 170 | await flushPromises() 171 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', { or: { 'id.eq': 1 } }, { signal: expect.any(AbortSignal) }) 172 | request.mockClear() 173 | wrapper.vm.$set(wrapper.vm.pgConfig.query.or, 'id.eq', 2) 174 | await flushPromises() 175 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', { or: { 'id.eq': 2 } }, { signal: expect.any(AbortSignal) }) 176 | }) 177 | 178 | it('calls $get when pgConfig.limit changed', async () => { 179 | wrapper.vm.$set(wrapper.vm.pgConfig, 'limit', 2) 180 | await flushPromises() 181 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { limit: 2, signal: expect.any(AbortSignal) }) 182 | wrapper.vm.$set(wrapper.vm.pgConfig, 'limit', 3) 183 | await flushPromises() 184 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { limit: 3, signal: expect.any(AbortSignal) }) 185 | }) 186 | 187 | it('calls $get when pgConfig.offset changed', async () => { 188 | wrapper.vm.$set(wrapper.vm.pgConfig, 'offset', 1) 189 | await flushPromises() 190 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { offset: 1, signal: expect.any(AbortSignal) }) 191 | wrapper.vm.$set(wrapper.vm.pgConfig, 'offset', 2) 192 | await flushPromises() 193 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { offset: 2, signal: expect.any(AbortSignal) }) 194 | }) 195 | 196 | it('calls $get when pgConfig.count changed', async () => { 197 | wrapper.vm.$set(wrapper.vm.pgConfig, 'count', 'exact') 198 | await flushPromises() 199 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { count: 'exact', signal: expect.any(AbortSignal) }) 200 | wrapper.vm.$set(wrapper.vm.pgConfig, 'count', 'estimated') 201 | await flushPromises() 202 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { count: 'estimated', signal: expect.any(AbortSignal) }) 203 | }) 204 | 205 | it('calls $get when pgConfig.apiRoot changed', async () => { 206 | wrapper.vm.$set(wrapper.vm.pgConfig, 'apiRoot', '/pk-api') 207 | await flushPromises() 208 | expect(request).toHaveBeenLastCalledWith('/pk-api', undefined, 'clients', 'GET', {}, { signal: expect.any(AbortSignal) }) 209 | wrapper.vm.$set(wrapper.vm.pgConfig, 'apiRoot', '/api') 210 | await flushPromises() 211 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { signal: expect.any(AbortSignal) }) 212 | }) 213 | 214 | it('calls $get when pgConfig.token changed', async () => { 215 | wrapper.vm.$set(wrapper.vm.pgConfig, 'token', 'test') 216 | await flushPromises() 217 | expect(request).toHaveBeenLastCalledWith('/api', 'test', 'clients', 'GET', {}, { signal: expect.any(AbortSignal) }) 218 | wrapper.vm.$set(wrapper.vm.pgConfig, 'token', undefined) 219 | await flushPromises() 220 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'clients', 'GET', {}, { signal: expect.any(AbortSignal) }) 221 | }) 222 | 223 | it('calls $get when pgConfig.route changed', async () => { 224 | wrapper.vm.$set(wrapper.vm.pgConfig, 'route', 'test') 225 | await flushPromises() 226 | expect(request).toHaveBeenLastCalledWith('/api', undefined, 'test', 'GET', {}, { signal: expect.any(AbortSignal) }) 227 | }) 228 | }) 229 | 230 | describe('error handling', () => { 231 | it('calls "onError" hook when using expired-token', async () => { 232 | wrapper = await new Promise(resolve => { 233 | const Component = { 234 | render () {}, 235 | mixins: [pg], 236 | data: () => ({ pgConfig: { route: 'clients', query: {}, token: 'expired-token' } }), 237 | onError: e => { 238 | expect(e).toMatchObject({ error: 'invalid_token', error_description: 'JWT expired' }) 239 | resolve(w) 240 | } 241 | } 242 | const w = shallowMount(Component) 243 | }) 244 | }) 245 | 246 | it('calls "onError" hook for request error', async () => { 247 | wrapper = await new Promise(resolve => { 248 | const Component = { 249 | render () {}, 250 | mixins: [pg], 251 | data: () => ({ pgConfig: { route: '404', query: {} } }), 252 | onError: e => { 253 | expect(e).toMatchObject({ status: 404 }) 254 | resolve(w) 255 | } 256 | } 257 | const w = shallowMount(Component) 258 | }) 259 | }) 260 | 261 | it('does not throw unexpectedly without onError hook', async () => { 262 | wrapper = await new Promise(resolve => { 263 | const Component = { 264 | render () {}, 265 | mixins: [pg], 266 | data: () => ({ pgConfig: { route: 'clients', query: {}, token: 'expired-token' } }), 267 | watch: { 268 | 'pg.$get.hasError' (hasError) { 269 | expect(hasError).toBe(true) 270 | resolve(w) 271 | } 272 | } 273 | } 274 | const w = shallowMount(Component) 275 | }) 276 | }) 277 | }) 278 | 279 | describe('reactivity', () => { 280 | beforeEach(() => { 281 | wrapper = shallowMount(Component) 282 | }) 283 | 284 | it('keeps pg when single is changed to same semantics', async () => { 285 | await flushPromises() 286 | const pgBefore = wrapper.vm.pg 287 | wrapper.vm.$set(wrapper.vm.pgConfig, 'single', undefined) 288 | await flushPromises() 289 | expect(wrapper.vm.pg).toBe(pgBefore) 290 | }) 291 | }) 292 | }) 293 | -------------------------------------------------------------------------------- /tests/unit/Query.spec.js: -------------------------------------------------------------------------------- 1 | import Query from '@/Query' 2 | 3 | // helper function for simple input-output testcases 4 | function itt (desc, inputObject, expectedResult) { 5 | it(desc, () => { 6 | expect(decodeURIComponent((new Query('/api', 'test', inputObject)).searchParams.toString())).toBe(expectedResult) 7 | }) 8 | } 9 | 10 | describe('Query', () => { 11 | it('implements URL interface', () => { 12 | // eslint-disable-next-line no-prototype-builtins 13 | expect(URL.isPrototypeOf(Query)).toBe(true) 14 | }) 15 | 16 | itt('returns empty query string without input', undefined, '') 17 | 18 | itt('returns empty query string with empty input', {}, '') 19 | 20 | it('returns path of URI properly', () => { 21 | // jsdom is configured in jest.config to have globalThis.location at localhost/nested/path 22 | expect((new Query('api', 'test', {})).toString()).toBe('http://localhost/nested/api/test') 23 | expect((new Query('/api', 'test', {})).toString()).toBe('http://localhost/api/test') 24 | expect((new Query('/api/', 'test', {})).toString()).toBe('http://localhost/api/test') 25 | expect((new Query('/api', 'rpc/test', {})).toString()).toBe('http://localhost/api/rpc/test') 26 | expect((new Query('/api', '/rpc/test', {})).toString()).toBe('http://localhost/api/rpc/test') 27 | expect((new Query('http://example.com/api', '/rpc/test', {})).toString()).toBe('http://example.com/api/rpc/test') 28 | expect((new Query('http://example.com/api/', '/rpc/test', {})).toString()).toBe('http://example.com/api/rpc/test') 29 | }) 30 | 31 | describe('arguments', () => { 32 | itt('sets string', { str: 'test' }, 'str=test') 33 | 34 | itt('sets empty string', { str: '' }, 'str=') 35 | 36 | itt('ignores undefined values', { str: undefined }, '') 37 | 38 | itt('represents integer as string', { int: 1 }, 'int=1') 39 | 40 | itt('does not quote float', { pi: 3.14 }, 'pi=3.14') 41 | 42 | itt('does not quote values with reserved postgrest characters', { str: 'test.test' }, 'str=test.test') 43 | 44 | itt('represents null as string', { null: null }, 'null=null') 45 | 46 | itt('does not quote string null', { str: 'null' }, 'str=null') 47 | 48 | itt('represents true as string', { bool: true }, 'bool=true') 49 | 50 | itt('does not quote string true', { str: 'true' }, 'str=true') 51 | 52 | itt('represents false as string', { bool: false }, 'bool=false') 53 | 54 | itt('does not quote string false', { str: 'false' }, 'str=false') 55 | 56 | itt('supports arguments with array values', { arr: [1, 2, 3] }, 'arr={1,2,3}') 57 | 58 | itt('quotes values with reserved postgrest characters in array arguments', { arr: ['a.b', 'x', 'c,d', 5, 'e:f', true, '(', ')'] }, 'arr={"a.b",x,"c,d",5,"e:f",true,"(",")"}') 59 | }) 60 | 61 | describe('horizontal filtering', () => { 62 | itt('sets single condition', { 'id.eq': 1 }, 'id=eq.1') 63 | 64 | itt('sets multiple conditions', { 65 | 'id.eq': 1, 66 | 'name.eq': 'test' 67 | }, 'id=eq.1&name=eq.test') 68 | 69 | itt('supports multiple operators', { 'id.not.eq': 1 }, 'id=not.eq.1') 70 | 71 | it('throws when using logical operator without object', () => { 72 | expect(() => new Query('/api', 'test', { or: true })).toThrow() 73 | expect(() => new Query('/api', 'test', { and: null })).toThrow() 74 | expect(() => new Query('/api', 'test', { 'not.or': '' })).toThrow() 75 | expect(() => new Query('/api', 'test', { 'not.and': [] })).toThrow() 76 | expect(() => new Query('/api', 'test', { or: 1 })).toThrow() 77 | }) 78 | 79 | itt('ignores logical operators set to undefined', { and: undefined }, '') 80 | 81 | itt('supports logical disjoining with "or" object', { 82 | or: { 83 | 'id.eq': 1, 84 | 'name.eq': 'test' 85 | } 86 | }, 'or=(id.eq.1,name.eq.test)') 87 | 88 | itt('quotes values with reserved postgrest characters in "or" object', { 89 | or: { 90 | 'id.gt': 1, 91 | 'salutation.eq': 'Hello, World!', 92 | 'simple.eq': 'stuff', 93 | 'name.eq': 'special: (case)' 94 | } 95 | }, 'or=(id.gt.1,salutation.eq."Hello,+World!",simple.eq.stuff,name.eq."special:+(case)")') 96 | 97 | itt('supports logical disjoining with aliased keys', { 98 | or: { 99 | 'f:nb.is': false, 100 | 'n:nb.is': null 101 | } 102 | }, 'or=(nb.is.false,nb.is.null)') 103 | 104 | itt('supports complex logical operations', { 105 | and: { 106 | 'grade.gte': 90, 107 | 'student.is': true, 108 | or: { 109 | 'age.gte': 14, 110 | 'age.is': null 111 | } 112 | } 113 | }, 'and=(grade.gte.90,student.is.true,or(age.gte.14,age.is.null))') 114 | 115 | itt('supports negated logical operators', { 116 | 'not.and': { 117 | 'grade.gte': 91, 118 | 'student.is': false, 119 | 'not.or': { 120 | 'age.gte': 15, 121 | 'age.not.is': null 122 | } 123 | } 124 | }, 'not.and=(grade.gte.91,student.is.false,not.or(age.gte.15,age.not.is.null))') 125 | 126 | itt('ignores empty logical operator', { 127 | and: {}, 128 | or: {} 129 | }, '') 130 | 131 | itt('supports full-text search operator options in key', { 'my_tsv.fts(french)': 'amusant' }, 'my_tsv=fts(french).amusant') 132 | 133 | itt('supports "in" operator with array', { 'id.in': [1, 2, 3] }, 'id=in.(1,2,3)') 134 | 135 | itt('quotes values with reserved postgrest characters in "in" operator', { 'str.in': ['a.b', 'x', 'c,d', 5, 'e:f', true, '(', ')'] }, 'str=in.("a.b",x,"c,d",5,"e:f",true,"(",")")') 136 | 137 | itt('supports other operators with arrays', { 'tags.cs': ['example', 'new'] }, 'tags=cs.{example,new}') 138 | 139 | itt('quotes values with reserved postgrest characters in array operators', { 'str.ov': ['a.b', 'x', 'c,d', 5, 'e:f', true, '(', ')'] }, 'str=ov.{"a.b",x,"c,d",5,"e:f",true,"(",")"}') 140 | 141 | itt('supports range operators with objects', { 'range.sl': { lower: 1, upper: 10 } }, 'range=sl.[1,10)') 142 | 143 | itt('supports range operators with objects and non-default include settings', { 'range.sl': { lower: 1, includeLower: false, upper: 10, includeUpper: true } }, 'range=sl.(1,10]') 144 | 145 | itt('supports nested filter on json field', { 146 | json_data: { 147 | 'blood_type.eq': 'A-', 148 | 'age.gt': 20 149 | } 150 | }, 'json_data->>blood_type=eq.A-&json_data->age=gt.20') 151 | 152 | it('throws when using logical operator nested in json field', () => { 153 | expect(() => new Query('/api', 'test', { 154 | json_field: { 155 | or: {} 156 | } 157 | })).toThrow() 158 | }) 159 | }) 160 | 161 | describe('vertical filtering', () => { 162 | itt('single column as string', { select: '*' }, 'select=*') 163 | 164 | itt('multiple columns as string', { select: 'id,name' }, 'select=id,name') 165 | 166 | itt('single column in array', { select: ['id'] }, 'select=id') 167 | 168 | itt('multiple columns in array', { select: ['id', 'name'] }, 'select=id,name') 169 | 170 | itt('single column in object', { select: { id: true } }, 'select=id') 171 | 172 | itt('multiple columns in object with truthy values', { select: { id: 1, name: {} } }, 'select=id,name') 173 | 174 | itt('ignore columns with falsy values', { 175 | select: { 176 | id: true, 177 | ignore1: false, 178 | ignore2: 0, 179 | ignore3: '', 180 | ignore4: null 181 | } 182 | }, 'select=id') 183 | 184 | itt('renames column in key', { select: { 'alias:id': {} } }, 'select=alias:id') 185 | 186 | itt('casts column from string', { select: { full_name: true, salary: 'text' } }, 'select=full_name,salary::text') 187 | 188 | itt('casts type from object', { select: { full_name: true, salary: { '::': 'text' } } }, 'select=full_name,salary::text') 189 | 190 | itt('uses json operators for nested objects', { 191 | select: { 192 | id: true, 193 | json_data: { 194 | blood_type: true, 195 | phones: true 196 | } 197 | } 198 | }, 'select=id,json_data->blood_type,json_data->phones') 199 | 200 | itt('uses json operators for nested arrays', { 201 | select: { 202 | id: true, 203 | json_data: { 204 | phones: [ 205 | { 206 | number: true 207 | } 208 | ] 209 | } 210 | } 211 | }, 'select=id,json_data->phones->0->number') 212 | 213 | itt('both json_data and subfield at the same time', { 214 | select: { 215 | id: true, 216 | 'jd:json_data': { 217 | 'bt:blood_type': true 218 | } 219 | } 220 | }, 'select=id,jd:json_data,bt:json_data->blood_type') 221 | 222 | itt('renames json_data in key, uses subfield and casts properly', { 223 | select: { 224 | id: true, 225 | 'jd:json_data': { 226 | '::': 'text', 227 | 'bt:blood_type': 'integer' 228 | } 229 | } 230 | }, 'select=id,jd:json_data::text,bt:json_data->blood_type::integer') 231 | 232 | itt('json field with quoted key', { 233 | select: { 234 | 'some->nested->"https://json-that-needs-quotes"': true 235 | } 236 | }, 'select=some->nested->"https://json-that-needs-quotes"') 237 | 238 | itt('json field with quoted key and aliased', { 239 | select: { 240 | 'alias:some->nested->"https://json-that-needs-quotes"': true 241 | } 242 | }, 'select=alias:some->nested->"https://json-that-needs-quotes"') 243 | 244 | itt('json field with quoted nested key', { 245 | select: { 246 | some: { 247 | nested: { 248 | '"https://json-that-needs-quotes"': true 249 | } 250 | } 251 | } 252 | }, 'select=some->nested->"https://json-that-needs-quotes"' 253 | ) 254 | 255 | itt('should parse quoted aliases with escaped quotes properly', { 256 | select: { 257 | '"a:\\":b":"x:y:z"': true 258 | } 259 | }, 'select="a:\\":b":"x:y:z"') 260 | }) 261 | 262 | describe('ordering', () => { 263 | itt('with string', { order: 'age.desc,height.asc' }, 'order=age.desc,height.asc') 264 | 265 | itt('with array of strings', { order: ['age.desc', 'height.asc'] }, 'order=age.desc,height.asc') 266 | 267 | itt('with array of arrays', { order: [['age', 'desc'], ['height', 'asc']] }, 'order=age.desc,height.asc') 268 | 269 | itt('with object', { 270 | order: { 271 | age: 'desc', 272 | height: 'asc.nullslast' 273 | } 274 | }, 'order=age.desc,height.asc.nullslast') 275 | 276 | itt('with object and defaults', { 277 | order: { 278 | age: true 279 | } 280 | }, 'order=age') 281 | }) 282 | 283 | describe('pagination', () => { 284 | itt('sets limit', { limit: 1 }, 'limit=1') 285 | 286 | itt('sets offset', { offset: 1 }, 'offset=1') 287 | }) 288 | 289 | describe('columns', () => { 290 | itt('sets string', { columns: 'source,publication_date,figure' }, 'columns=source,publication_date,figure') 291 | 292 | itt('sets from array', { columns: ['source', 'publication_date', 'figure'] }, 'columns=source,publication_date,figure') 293 | }) 294 | 295 | describe('on_conflict', () => { 296 | itt('sets string', { on_conflict: 'source' }, 'on_conflict=source') 297 | 298 | itt('sets from array', { on_conflict: ['source', 'publication_date', 'figure'] }, 'on_conflict=source,publication_date,figure') 299 | }) 300 | 301 | describe('embedding resources', () => { 302 | itt('simple form', { 303 | select: { 304 | title: true, 305 | directors: { 306 | select: { 307 | id: true, 308 | last_name: true 309 | } 310 | } 311 | } 312 | }, 'select=title,directors(id,last_name)') 313 | 314 | itt('nested', { 315 | select: { 316 | title: true, 317 | directors: { 318 | select: { 319 | id: true, 320 | last_name: true, 321 | awards: { 322 | select: '*' 323 | } 324 | } 325 | } 326 | } 327 | }, 'select=title,directors(id,last_name,awards(*))') 328 | 329 | itt('with alias', { 330 | select: { 331 | title: true, 332 | 'director:directors': { 333 | select: { 334 | id: true, 335 | last_name: true 336 | } 337 | } 338 | } 339 | }, 'select=title,director:directors(id,last_name)') 340 | 341 | itt('with order', { 342 | select: { 343 | '*': true, 344 | actors: { 345 | select: '*', 346 | order: ['last_name', 'first_name'] 347 | } 348 | } 349 | }, 'select=*,actors(*)&actors.order=last_name,first_name') 350 | 351 | itt('with filters', { 352 | select: { 353 | '*': true, 354 | roles: { 355 | select: '*', 356 | 'character.in': ['Chico', 'Harpo', 'Groucho'] 357 | } 358 | } 359 | }, 'select=*,roles(*)&roles.character=in.(Chico,Harpo,Groucho)') 360 | 361 | itt('with nested fields filters', { 362 | select: { 363 | '*': true, 364 | roles: { 365 | select: '*' 366 | } 367 | }, 368 | 'roles.character.eq': 'Gummo' 369 | }, 'select=*,roles(*)&roles.character=eq.Gummo') 370 | 371 | itt('with nested negated fields filters', { 372 | select: { 373 | '*': true, 374 | roles: { 375 | select: '*' 376 | } 377 | }, 378 | 'roles.character.not.eq': 'Gummo' 379 | }, 'select=*,roles(*)&roles.character=not.eq.Gummo') 380 | 381 | itt('with complex filter', { 382 | select: { 383 | '*': true, 384 | roles: { 385 | select: '*', 386 | or: { 387 | '0:character.eq': 'Gummo', 388 | '1:character.eq': 'Zeppo' 389 | } 390 | } 391 | } 392 | }, 'select=*,roles(*)&roles.or=(character.eq.Gummo,character.eq.Zeppo)') 393 | 394 | itt('with pagination', { 395 | select: { 396 | '*': true, 397 | actors: { 398 | select: '*', 399 | limit: 10, 400 | offset: 2 401 | } 402 | } 403 | }, 'select=*,actors(*)&actors.limit=10&actors.offset=2') 404 | 405 | itt('with alias and filters', { 406 | select: { 407 | '*': true, 408 | '90_comps:competitions': { 409 | select: 'name', 410 | 'year.eq': 1990 411 | }, 412 | '91_comps:competitions': { 413 | select: 'name', 414 | 'year.eq': 1991 415 | } 416 | } 417 | }, 'select=*,90_comps:competitions(name),91_comps:competitions(name)&90_comps.year=eq.1990&91_comps.year=eq.1991') 418 | 419 | itt('multiple', { 420 | select: { 421 | rank: true, 422 | competitions: { 423 | select: ['name', 'year'] 424 | }, 425 | films: { 426 | select: 'title' 427 | } 428 | }, 429 | 'rank.eq': 5 430 | }, 'select=rank,competitions(name,year),films(title)&rank=eq.5') 431 | 432 | itt('with hint', { 433 | select: { 434 | '*': true, 435 | 'central_addresses!billing_address': { 436 | select: '*' 437 | } 438 | } 439 | }, 'select=*,central_addresses!billing_address(*)') 440 | }) 441 | }) 442 | -------------------------------------------------------------------------------- /tests/unit/Request.spec.js: -------------------------------------------------------------------------------- 1 | import request, { setDefaultHeaders } from '@/request' 2 | import GenericModel from '@/GenericModel' 3 | import { FetchError, AuthError } from '@/errors' 4 | 5 | describe('request method', () => { 6 | beforeEach(() => { 7 | // just reset .mock data, but not .mockResponse 8 | fetch.mockClear() 9 | setDefaultHeaders() 10 | }) 11 | 12 | it('sends a request with method GET, POST, PUT, PATCH or DELETE', async () => { 13 | await request('/api', '', 'clients', 'GET', {}) 14 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 15 | method: 'GET' 16 | })) 17 | 18 | await request('/api', '', 'clients', 'POST', {}) 19 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 20 | method: 'POST' 21 | })) 22 | 23 | await request('/api', '', 'clients', 'PUT', {}) 24 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 25 | method: 'PUT' 26 | })) 27 | 28 | await request('/api', '', 'clients', 'PATCH', {}) 29 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 30 | method: 'PATCH' 31 | })) 32 | 33 | await request('/api', '', 'clients', 'DELETE', {}) 34 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 35 | method: 'DELETE' 36 | })) 37 | }) 38 | 39 | it('correctly throws FetchError with full error body', async () => { 40 | expect.assertions(5) 41 | try { 42 | await request('/api', '', '404', 'GET', {}) 43 | } catch (e) { 44 | expect(e instanceof FetchError).toBe(true) 45 | expect(e.code).toBe('404 error code') 46 | expect(e.hint).toBe('404 error hint') 47 | expect(e.details).toBe('404 error details') 48 | expect(e.message).toBe('404 error message') 49 | } 50 | }) 51 | 52 | it('correctly throws AuthError with full error body', async () => { 53 | expect.assertions(5) 54 | try { 55 | await request('/autherror', 'expired-token', '', 'GET', {}) 56 | console.log(fetch.mock.calls[0][1].headers) 57 | } catch (e) { 58 | expect(e instanceof AuthError).toBe(true) 59 | expect(e.code).toBe('401 error code') 60 | expect(e.hint).toBe('401 error hint') 61 | expect(e.details).toBe('401 error details') 62 | expect(e.message).toBe('401 error message') 63 | } 64 | }) 65 | 66 | it('does not throw if query argument is undefined', async () => { 67 | await expect(request('/api', '', 'clients', 'GET')).resolves.toBeTruthy() 68 | }) 69 | 70 | it('appends query to URL', async () => { 71 | await request('/api', '', 'clients', 'GET', { select: ['id', 'name'] }) 72 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients?select=' + encodeURIComponent('id,name'), expect.anything()) 73 | 74 | await request('/api', '', 'clients', 'POST', { 'id.eq': 1 }) 75 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients?id=' + encodeURIComponent('eq.1'), expect.anything()) 76 | 77 | await request('/api', '', 'clients', 'PATCH', { order: 'name.asc' }) 78 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients?order=' + encodeURIComponent('name.asc'), expect.anything()) 79 | 80 | await request('/api', '', 'clients', 'DELETE', { limit: 1, offset: 2 }) 81 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients?limit=1&offset=2', expect.anything()) 82 | }) 83 | 84 | it('parses "accept" option', async () => { 85 | await request('/api', '', 'clients', 'GET', {}, { accept: 'single' }) 86 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 87 | headers: new Headers({ 88 | Accept: 'application/vnd.pgrst.object+json' 89 | }) 90 | })) 91 | 92 | await request('/api', '', 'clients', 'POST', {}, { accept: 'binary' }) 93 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 94 | headers: new Headers({ 95 | Accept: 'application/octet-stream' 96 | }) 97 | })) 98 | 99 | await request('/api', '', 'clients', 'POST', {}, { accept: 'text' }) 100 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 101 | headers: new Headers({ 102 | Accept: 'text/plain' 103 | }) 104 | })) 105 | 106 | await request('/api', '', 'clients', 'PATCH', {}, { accept: undefined }) 107 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 108 | headers: new Headers({ 109 | Accept: 'application/json' 110 | }) 111 | })) 112 | 113 | await request('/api', '', 'clients', 'DELETE', {}, { accept: 'custom-accept-header' }) 114 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 115 | headers: new Headers({ 116 | Accept: 'custom-accept-header' 117 | }) 118 | })) 119 | }) 120 | 121 | describe('set range headers for "offset" and "limit" options', () => { 122 | it('offset 0', async () => { 123 | await request('/api', '', 'clients', 'GET', {}, { offset: 0 }) 124 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 125 | headers: new Headers({ 126 | Accept: 'application/json', 127 | 'Range-Unit': 'items', 128 | Range: '0-' 129 | }) 130 | })) 131 | }) 132 | 133 | it('offset 1', async () => { 134 | await request('/api', '', 'clients', 'GET', {}, { offset: 1 }) 135 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 136 | headers: new Headers({ 137 | Accept: 'application/json', 138 | 'Range-Unit': 'items', 139 | Range: '1-' 140 | }) 141 | })) 142 | }) 143 | 144 | it('offset > 1', async () => { 145 | await request('/api', '', 'clients', 'GET', {}, { offset: 5 }) 146 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 147 | headers: new Headers({ 148 | Accept: 'application/json', 149 | 'Range-Unit': 'items', 150 | Range: '5-' 151 | }) 152 | })) 153 | }) 154 | 155 | it('limit 0', async () => { 156 | await request('/api', '', 'clients', 'GET', {}, { limit: 0 }) 157 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 158 | headers: new Headers({ 159 | Accept: 'application/json', 160 | 'Range-Unit': 'items', 161 | Range: '-0' 162 | }) 163 | })) 164 | }) 165 | 166 | it('limit 1', async () => { 167 | await request('/api', '', 'clients', 'GET', {}, { limit: 1 }) 168 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 169 | headers: new Headers({ 170 | Accept: 'application/json', 171 | 'Range-Unit': 'items', 172 | Range: '0-0' 173 | }) 174 | })) 175 | }) 176 | 177 | it('limit > 1', async () => { 178 | await request('/api', '', 'clients', 'GET', {}, { limit: 10 }) 179 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 180 | headers: new Headers({ 181 | Accept: 'application/json', 182 | 'Range-Unit': 'items', 183 | Range: '0-9' 184 | }) 185 | })) 186 | }) 187 | 188 | it('offset > 0 and limit 0', async () => { 189 | await request('/api', '', 'clients', 'GET', {}, { offset: 5, limit: 0 }) 190 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 191 | headers: new Headers({ 192 | Accept: 'application/json', 193 | 'Range-Unit': 'items', 194 | Range: '-0' 195 | }) 196 | })) 197 | }) 198 | 199 | it('offset > 0 and limit 1', async () => { 200 | await request('/api', '', 'clients', 'GET', {}, { offset: 5, limit: 1 }) 201 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 202 | headers: new Headers({ 203 | Accept: 'application/json', 204 | 'Range-Unit': 'items', 205 | Range: '5-5' 206 | }) 207 | })) 208 | }) 209 | 210 | it('offset > 0 and limit > 1', async () => { 211 | await request('/api', '', 'clients', 'GET', {}, { offset: 5, limit: 10 }) 212 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 213 | headers: new Headers({ 214 | Accept: 'application/json', 215 | 'Range-Unit': 'items', 216 | Range: '5-14' 217 | }) 218 | })) 219 | }) 220 | }) 221 | 222 | it('parses "return" option', async () => { 223 | await request('/api', '', 'clients', 'POST', {}, { return: 'representation' }) 224 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 225 | headers: new Headers({ 226 | Accept: 'application/json', 227 | Prefer: 'return=representation' 228 | }) 229 | })) 230 | }) 231 | 232 | it('parses "count" option', async () => { 233 | await request('/api', '', 'clients', 'GET', {}, { count: 'exact' }) 234 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 235 | headers: new Headers({ 236 | Accept: 'application/json', 237 | Prefer: 'count=exact' 238 | }) 239 | })) 240 | }) 241 | 242 | it('parses "params" option', async () => { 243 | await request('/api', '', 'clients', 'POST', {}, { params: 'single-object' }) 244 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 245 | headers: new Headers({ 246 | Accept: 'application/json', 247 | Prefer: 'params=single-object' 248 | }) 249 | })) 250 | 251 | await request('/api', '', 'clients', 'POST', {}, { params: 'multiple-objects' }) 252 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 253 | headers: new Headers({ 254 | Accept: 'application/json', 255 | Prefer: 'params=multiple-objects' 256 | }) 257 | })) 258 | }) 259 | 260 | it('parses "resolution" option', async () => { 261 | await request('/api', '', 'clients', 'POST', {}, { resolution: 'merge-duplicates' }) 262 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 263 | headers: new Headers({ 264 | Accept: 'application/json', 265 | Prefer: 'resolution=merge-duplicates' 266 | }) 267 | })) 268 | 269 | await request('/api', '', 'clients', 'POST', {}, { resolution: 'ignore-duplicates' }) 270 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 271 | headers: new Headers({ 272 | Accept: 'application/json', 273 | Prefer: 'resolution=ignore-duplicates' 274 | }) 275 | })) 276 | }) 277 | 278 | it('combines multiple prefer options', async () => { 279 | await request('/api', '', 'clients', 'PATCH', {}, { count: 'exact', return: 'minimal' }) 280 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 281 | headers: new Headers({ 282 | Accept: 'application/json', 283 | Prefer: 'return=minimal,count=exact' 284 | }) 285 | })) 286 | }) 287 | 288 | it('does not override default prefer header', async () => { 289 | setDefaultHeaders({ Prefer: 'timezone=Europe/Berlin' }) 290 | await request('/api', '', 'clients', 'PATCH', {}, { count: 'exact', return: 'minimal' }) 291 | const expectedHeaders = new Headers({ Prefer: 'timezone=Europe/Berlin' }) 292 | expectedHeaders.set('Accept', 'application/json') 293 | expectedHeaders.append('Prefer', 'return=minimal,count=exact') 294 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 295 | headers: expectedHeaders 296 | })) 297 | }) 298 | 299 | it('passes "headers" option as override', async () => { 300 | const headers = { 301 | Prefer: 'custom-prefer-header', 302 | Accept: 'custom-accept-header', 303 | 'x-header': 'custom-x-header' 304 | } 305 | 306 | await request('/api', '', 'clients', 'GET', {}, { headers }) 307 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 308 | headers: new Headers(headers) 309 | })) 310 | 311 | await request('/api', '', 'clients', 'POST', {}, { accept: 'binary', return: 'minimal', headers }) 312 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 313 | headers: new Headers(headers) 314 | })) 315 | }) 316 | 317 | it('passes "signal" option to fetch', async () => { 318 | const controller = new AbortController() 319 | const signal = controller.signal 320 | await request('/api', '', 'clients', 'GET', {}, { signal }) 321 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 322 | signal 323 | })) 324 | }) 325 | 326 | it('sends authorization header if argument "token" is set', async () => { 327 | const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiamRvZSIsImV4cCI6MTQ3NTUxNjI1MH0.GYDZV3yM0gqvuEtJmfpplLBXSGYnke_Pvnl0tbKAjB' 328 | await request('/api', token, 'clients', 'GET', {}) 329 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 330 | headers: new Headers({ 331 | Accept: 'application/json', 332 | Authorization: `Bearer ${token}` 333 | }) 334 | })) 335 | }) 336 | 337 | describe('body argument', () => { 338 | it('is stringified when plain object and sets content-type header to application/json', async () => { 339 | const body = { 340 | string: 'value', 341 | number: 5 342 | } 343 | await request('/api', '', 'clients', 'POST', {}, {}, body) 344 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 345 | headers: new Headers({ 346 | Accept: 'application/json', 347 | 'Content-Type': 'application/json' 348 | }), 349 | body: JSON.stringify(body) 350 | })) 351 | }) 352 | 353 | it('is stringified when generic model and sets content-type header to application/json', async () => { 354 | const body = new GenericModel({}, { 355 | string: 'value', number: 5 356 | }) 357 | await request('/api', '', 'clients', 'POST', {}, {}, body) 358 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 359 | headers: new Headers({ 360 | Accept: 'application/json', 361 | 'Content-Type': 'application/json' 362 | }), 363 | body: JSON.stringify(body) 364 | })) 365 | }) 366 | 367 | it('is sent as-is when blob', async () => { 368 | const body = new Blob() 369 | await request('/api', '', 'clients', 'POST', {}, {}, body) 370 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 371 | body 372 | })) 373 | }) 374 | 375 | it('is sent as-is when form data', async () => { 376 | const body = new FormData() 377 | await request('/api', '', 'clients', 'POST', {}, {}, body) 378 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 379 | body 380 | })) 381 | }) 382 | 383 | it('is sent as-is when string', async () => { 384 | const body = 'value' 385 | await request('/api', '', 'clients', 'POST', {}, {}, body) 386 | expect(fetch).toHaveBeenLastCalledWith('http://localhost/api/clients', expect.objectContaining({ 387 | body: 'value' 388 | })) 389 | }) 390 | }) 391 | }) 392 | -------------------------------------------------------------------------------- /docs/guide/index.md: -------------------------------------------------------------------------------- 1 | # Guide 2 | 3 | ## Installation 4 | 5 | To get started, install vue-postgrest via your package manager: 6 | 7 | ``` bash 8 | yarn add vue-postgrest 9 | ``` 10 | 11 | ``` bash 12 | npm install vue-postgrest 13 | ``` 14 | 15 | The default export provides the plugin to use in your `main.js`. When installing the plugin, you can pass the root URI of your PostgREST server as a plugin option. All requests to the API made by the mixin, component or instance methods will use this URI as base. 16 | 17 | ``` js 18 | import Vue from 'vue' 19 | import VuePostgrest from 'vue-postgrest' 20 | 21 | Vue.use(VuePostgrest, { 22 | apiRoot: '/api/v1/' 23 | }) 24 | ``` 25 | 26 | When installed, the plugin registers the `postgrest` component globally on your vue instance for use in your templates, as well as several instance methods on `vm.$postgrest`. The most convenient way is to use the `pg` mixin, though. 27 | 28 | ::: warning 29 | You have to install the plugin, even if you only use the mixin in your components! 30 | ::: 31 | 32 | ## Retrieving Data 33 | 34 | To use the mixin, provide a `pgConfig` object on your component instance with the `route` option set to the table/view you want to query: 35 | 36 | ``` vue 37 | 52 | ``` 53 | 54 | ### Column Filtering 55 | 56 | To access the data sent by the server, use `this.pg`, which is an array holding the server response by default. 57 | 58 | ``` vue 59 | 64 | ``` 65 | 66 | Using the mixin option `single: true` will set the `Accept` header to tell PostgREST to return a single item unenclosed by an array. In this case you can use `this.pg` to access the returned item directly. 67 | 68 | The mixin option `query` is used to construct the PostgREST query string. Use `query.select` for column filtering like this: 69 | 70 | ``` vue 71 | 80 | ``` 81 | 82 | The select key alternatively accepts an object with column names as keys. You can use aliasing and casting like this: 83 | 84 | ``` vue 85 | 98 | ``` 99 | 100 | ### Ordering 101 | 102 | To order your response, you can either pass an array of strings or an object to `query.order`. E.g.: 103 | 104 | ``` vue 105 | 113 | 114 | // or 115 | 116 | 127 | ``` 128 | 129 | ### Row Filtering 130 | 131 | The `query` constructs column conditions from it's keys. Operators are dot-appended to the key. 132 | E.g. to use multiple conditions: 133 | 134 | ``` vue 135 | 144 | ``` 145 | 146 | When passing arrays, the resulting query string is constructed based on the used operator! See [Arrays](../query/#arrays). Furthermore, `undefined` values will exclude the column condition from the query string - this can be useful if you create your query object dynamically. 147 | 148 | 149 | ::: tip 150 | For convenient creation of range objects see [Range Objects](../query/#range-objects)! 151 | ::: 152 | 153 | ### Embedding 154 | 155 | PostgREST offers an easy way to handle relationships between tables/views. You can leverage this by using the embed syntax in your queries. The syntax to filter, order or select embeds corresponds to the root level of the query object: 156 | 157 | ``` vue 158 | 173 | ``` 174 | 175 | **Important:** If you omit the `select` key in an embed object, it is assumed that you want to access a JSON-field instead of embedding a resource! See [JSON Columns](../query/#json-columns) for details. 176 | 177 | ### Loading / Refreshing 178 | 179 | For monitoring the current status of the request, you can use `this.pg.$get` which is an [ObservableFunction](../api/#observable-function). `pg.$get.isPending` tells you, if a request is still pending: 180 | 181 | ``` vue 182 | 188 | ``` 189 | 190 | You can call the `$get` function to rerun the get request, e.g. if you need to refresh your data manually. 191 | 192 | **Note:** The `$get` function also exposes getters for information about failed requests, see [error handling](./#error-handling). 193 | 194 | ### Pagination 195 | 196 | Server side pagination can be achieved by setting the mixin options `limit` and `offset`. When used as a mixin option (or component prop), these options set the appropriate request headers automatically. When used inside a query object, limit and offset will be appended to the query string. 197 | 198 | **Range** 199 | To get information about the paginated response, the mixin provides the `pg.$range` object, based on the response's `Content-Range` header. To get the total count of available rows, use the mixin option `count = 'exact'` which sets the corresponding `Prefer` header. 200 | 201 | ``` vue 202 | 209 | 210 | 235 | ``` 236 | 237 | ### Multiple Requests 238 | 239 | Sometimes it may be necessary to access multiple tables/views or query the same route twice the from the same component. You can use the `postgrest` component for this. 240 | 241 | The component takes the same options as the `pg` mixin as props and provides it's scope as slot props, so you can use it in your template like this: 242 | 243 | ``` vue 244 | 271 | ``` 272 | 273 | **Note:** If you encounter situations where it is more convenient to do this programmatically, you can also use instance methods! The `this.$postgrest` exposes a [Route](../api/#postgrest-route) for each table/view that is available in your schema. You could then rewrite the above example like this: 274 | 275 | ``` vue 276 | 286 | 287 | 319 | .... 320 | ``` 321 | 322 | ## Modifying Data 323 | 324 | Each item provided by the mixin or the component is a [Generic Model](../api/#genericmodel), which is a wrapper for the entity received from the server with some added methods and getters. 325 | 326 | Getting an item, modifying it's data and patching it on the server can be as simple as: 327 | 328 | ``` vue 329 | 334 | 335 | 352 | ``` 353 | 354 | ::: warning 355 | The instance methods `$postgrest.ROUTE.METHOD` do not wrap the response in GenericModels but return the fetch `Response` directly. 356 | ::: 357 | 358 | ### Model State 359 | 360 | Just like the mixin method `pg.$get`, the request-specific methods provided by a GenericModel are [ObservableFunctions](../api/#observablefunction). This means, you can check on the status of pending requests or errors via the respective getters. In addition, GenericModels provide the getter `model.$isDirty`, which indicates if the model's data changed from it's initial state, as well as a `model.$reset()` method, which resets the data to it's initial state. 361 | 362 | **Note:** The model is updated after $patch requests by default and `initial state` is set to the updated data. If you don't want to update the model, e.g. when doing a partial patch, set the `$patch` option `return='minimal'`. 363 | 364 | The first argument to the `model.$patch` method is an options object. The second argument to `$patch` is an object with additional patch data. See [$patch](../api/#patch-data-options) for details. 365 | 366 | A more extensive example could look like this: 367 | 368 | ``` vue 369 | 380 | 381 | 408 | ``` 409 | 410 | Using the `postgrest` component and it's slot scope for patching: 411 | 412 | ``` vue 413 | 434 | 435 | 444 | ``` 445 | 446 | ## Creating Models 447 | 448 | When the mixin option `single` is `false` (the default) `this.pg` is actually an instance of a [GenericCollection](../api/#genericcollection). The `GenericCollection` has a method `pg.$new(...)` to create new `GenericModel`s. You can then call `$post()` on the returned models to make a `POST` request. 449 | 450 | ``` vue 451 | 457 | 458 | 485 | ``` 486 | 487 | ### Upserts 488 | 489 | You can do an upsert (insert or update, when it already exists) with either a `POST` or a `PUT` request. To make a `PUT` request, just call `$put()` on the model - but make sure to set the primary key on the model first. 490 | 491 | Alternatively, you can use all options that a [route](../api/#postgrest-route) offers with `$post()`. To perform an upsert, you can pass the `resolution` option, which sets the resolution part of the `Prefer` header. To set the `on_conflict` query string parameter, see [Query](../query/#on-conflict). 492 | 493 | Example for `$post()` upsert: 494 | 495 | ``` vue 496 | 502 | 503 | 527 | ``` 528 | 529 | ## Handling Errors 530 | 531 | ### Mixin / Component 532 | 533 | The mixin calls the `onError` hook on your component instance whenever a [FetchError](../api/#fetcherror) or an [AuthError](../api/#autherror) is thrown. To react to errors from the `postgrest` component, use the `error` event. The error object is passed to the hook/event. 534 | 535 | ### GenericModel / Instance Methods / Stored Procedures 536 | 537 | All request-specific methods from a [GenericCollection](../api/#genericcollection) or [GenericModel](../api/#genericmodel), as well as the [instance methods](../api/#instancemethods) and [stored procedure calls](../api/#postgrest-rpc-function-name-options-params) throw [AuthError](../api/#autherror) and [FetchError](../api/#fetcherror). 538 | Additionally, the generic model methods throw [PrimaryKeyError](../api/#primarykeyerror). 539 | 540 | ::: tip 541 | You can test whether a schema was found for the base URI by catching [SchemaNotFoundError](../api/#schemanotfounderror) on [$postgrest.$ready](../api/#postgrest-ready). 542 | ::: 543 | 544 | ### Full Example 545 | 546 | ``` vue 547 | 566 | 567 | 622 | ``` 623 | 624 | ## Stored Procedures 625 | 626 | For calling stored procedures, the instance method `$postgrest.rpc` is provided. On loading the schema, all available stored procedures are registered here. The stored procedure call accepts an object containing the parameters that are passed to the stored procedure and an options object. By default, RPCs are called with the request method `POST`, you can set the rpc option `get: true` if you want to call a RPC with `GET` instead. For setting the `Accept` header, use the option `accept`. 627 | 628 | ``` vue 629 | 647 | ``` 648 | 649 | ::: tip 650 | If you want to call a RPC before the schema is loaded, you can call `$postgrest.rpc` directly by passing the name of the stored procedure that should be called as the first argument, followed by the rpc parameters and options. See [RPC](../api/#postgrest-rpc-function-name-options-params) for details. 651 | ::: 652 | 653 | ## Authentication 654 | 655 | The most convenient way to set the `Authorization` header to include your jwt token is to use the [setDefaultToken](../api/#setdefaulttoken) method exported by the module. This method sets the token to use for all subsequent communication with the PostgREST server. 656 | 657 | ``` vue 658 | import { setDefaultToken } from 'vue-postgrest' 659 | 660 | 667 | ``` 668 | 669 | If you want to overwrite the token used for specific requests, you can either use the mixin option `token` or the component prop, respectively. 670 | 671 | To handle rejected requests due to token errors, use the `AuthError` that is thrown when the server rejects your token, see [Handling Errors](./#handling-errors) for details. 672 | 673 | ::: tip 674 | You can use the instance method `$postgrest` to create a new schema. If you set the first argument (`apiRoot`) to undefined, a new schema is created with the base URI used by the default schema. You can pass an auth token as the second argument, which will then be used for subsequent requests with the new schema. 675 | ::: 676 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ## Module Exports 4 | 5 | The `vue-postgrest` module exports a plugin, a mixin and several helper functions and classes. 6 | 7 | ### VuePostgrest - Plugin 8 | 9 | - **Type:** `VuePlugin` 10 | 11 | - **Usage:** 12 | 13 | Installing the plugin registers the instance method $postgrest on your Vue instance. See available [plugin options](./#plugin-options). 14 | 15 | ::: warning 16 | You have to install the plugin in any case, even if you only use the mixin in your components! 17 | ::: 18 | 19 | - **Example:** 20 | 21 | ``` js 22 | import Vue from 'vue' 23 | import VuePostgrest from 'vue-postgrest' 24 | 25 | Vue.use(VuePostgrest) 26 | ``` 27 | 28 | ### pg - Mixin 29 | 30 | - **Type:** `VueMixin` 31 | 32 | - **Usage:** 33 | 34 | Import the `pg` mixin and include it in your component's `mixin` attribute. The component has to provide a `pgConfig` object specifying the [mixin options](./#mixin-options). 35 | 36 | - **Example:** 37 | 38 | ``` js 39 | import { pg } from 'vue-postgrest' 40 | 41 | export default { 42 | name: 'Component', 43 | mixins: [pg], 44 | data () { 45 | return { 46 | pgConfig: { 47 | route: 'inhabitants', 48 | query: { 49 | select: ['id' 'name', 'age'] 50 | } 51 | } 52 | } 53 | }, 54 | onError (err) { 55 | console.log(err) 56 | } 57 | } 58 | ``` 59 | 60 | ### resetSchemaCache() 61 | 62 | - **Type:** `Function` 63 | 64 | - **Arguments:** 65 | 66 | - **Returns:** `undefined` 67 | 68 | - **Usage:** 69 | 70 | Reset the schema cache, i.e. the inverse of `$postgrest.$ready`. Useful in unit tests. 71 | 72 | ### setDefaultToken(token) 73 | 74 | - **Type:** `Function` 75 | 76 | - **Arguments:** 77 | - `{string} token` 78 | 79 | - **Returns:** `undefined` 80 | 81 | - **Usage:** 82 | 83 | Set the default access token used for all authentication with the API. Sets the appropriate `Authorization` header. 84 | 85 | ::: tip 86 | You can override the token locally by setting the corresponding [component prop](./#component-props) or [mixin option](./#token). 87 | ::: 88 | 89 | - **Example:** 90 | 91 | ``` js 92 | import { setDefaultToken } from 'vue-postgrest' 93 | 94 | export default { 95 | name: 'App', 96 | async mounted: { 97 | // authenticate by calling a stored procedure 98 | const resp = await this.$postgrest.rpc('authenticate') 99 | // parsing fetch response body to json 100 | const token = await resp.json() 101 | // setting default access token globally 102 | setDefaultToken(token) 103 | } 104 | } 105 | ``` 106 | 107 | ### usePostgrest(apiRoot, token) 108 | 109 | - **Type:** `Function` 110 | 111 | - **Arguments:** 112 | - `{string} apiRoot` 113 | - `{string} token` 114 | 115 | - **Returns:** `Schema` 116 | 117 | - **Usage:** 118 | 119 | Used to create a new schema for the specified baseUri with the specified default auth token. If `apiRoot` is undefined, the apiRoot of the existing Schema is used. 120 | 121 | The returned value is the same as `this.$postgrest` and can be used without the vue instance, e.g. in a store module. 122 | 123 | ### AuthError 124 | 125 | Instances of AuthError are thrown when the server rejects the authentication token. 126 | 127 | ### SchemaNotFoundError 128 | 129 | Instances of SchemaNotFoundError are thrown, when there is no valid postgrest schema at the base URI. 130 | 131 | ### FetchError 132 | 133 | Instances of FetchError are thrown on generic errors from Fetch that don't trigger the throw of more specific errors. 134 | 135 | ### PrimaryKeyError 136 | 137 | Instances of PrimaryKeyError are thrown, when no primary keys are found for the specified `route` on the schema or no valid primary key is found on a [GenericModel](./#genericmodel). 138 | 139 | ## Plugin Options 140 | 141 | Global options can be set when initializing Vue-Postgrest with `Vue.use`. 142 | 143 | ### apiRoot 144 | 145 | - **Type:** `String` 146 | 147 | - **Default:** `''` 148 | 149 | - **Details:** 150 | 151 | The URI used as the base for all requests to the API by the mixin, global and local components, as well as the global vue-postgrest instance. This should be the URI to your PostgREST installation. 152 | 153 | ::: tip 154 | You can override the base URI locally by setting the [component prop](./#component-props) or [mixin option](./#apiroot-2). 155 | ::: 156 | 157 | - **Example:** 158 | 159 | ``` js 160 | import VuePostgrest from 'vue-postgrest' 161 | 162 | Vue.use(VuePostgrest, { 163 | apiRoot: '/api/' 164 | }) 165 | ``` 166 | 167 | ### headers 168 | 169 | - **Type:** `Object` 170 | 171 | - **Default:** `{}` 172 | 173 | - **Details:** 174 | 175 | A key/value mapping of default headers to send with each request. 176 | 177 | - **Example:** 178 | 179 | ``` js 180 | import VuePostgrest from 'vue-postgrest' 181 | 182 | Vue.use(VuePostgrest, { 183 | apiRoot: '/api/', 184 | headers: { 185 | Prefer: 'timezone=' + Intl.DateTimeFormat().resolvedOptions().timeZone 186 | } 187 | }) 188 | ``` 189 | 190 | ## Mixin Options 191 | 192 | Mixin options are set in the component using the `pg` mixin by setting the `pgConfig` object on the component instance. 193 | 194 | ### apiRoot 195 | 196 | - **Type:** `String` 197 | 198 | - **Default:** Global [plugin option](./#plugin-options) 199 | 200 | - **Details:** 201 | 202 | The URI used as the base for all requests to the API by the mixin, global and local components, as well as the global vue-postgrest instance. This should be the URI to your PostgREST installation. 203 | 204 | ::: tip 205 | This overrides the global [plugin option](./#apiroot)! 206 | ::: 207 | 208 | - **Example:** 209 | 210 | ``` js 211 | import { pg } from 'vue-postgrest' 212 | 213 | export default { 214 | name: 'Component', 215 | mixins: [pg], 216 | data () { 217 | return { 218 | pgConfig: { 219 | apiRoot: '/another-api/' 220 | } 221 | } 222 | } 223 | } 224 | ``` 225 | 226 | ### route 227 | 228 | - **Type:** `String` 229 | 230 | - **Details:** 231 | 232 | The table/view that is queried. 233 | 234 | - **Example:** 235 | 236 | ``` js 237 | import { pg } from 'vue-postgrest' 238 | 239 | export default { 240 | name: 'Component', 241 | mixins: [pg], 242 | data () { 243 | return { 244 | pgConfig: { 245 | route: 'clients' 246 | } 247 | } 248 | } 249 | } 250 | ``` 251 | 252 | ### token 253 | 254 | - **Type:** `String` 255 | 256 | - **Default:** `undefined` 257 | 258 | - **Details:** 259 | 260 | The access token used for authorizing the connection to the API. This options sets the `Authorization` header for all requests. 261 | 262 | See also [Client Auth](https://postgrest.org/en/latest/auth.html#client-auth) in the PostgREST documentation. 263 | 264 | ::: tip 265 | You can set this globally with the [setDefaultToken method](./#setdefaulttoken-token)! 266 | ::: 267 | 268 | - **Example:** 269 | 270 | ``` js 271 | import { pg } from 'vue-postgrest' 272 | 273 | export default { 274 | name: 'Component', 275 | mixins: [pg], 276 | data () { 277 | return { 278 | pgConfig: { 279 | route: 'inhabitants', 280 | token: 'YOUR_API_TOKEN' 281 | } 282 | } 283 | } 284 | } 285 | ``` 286 | 287 | ### query 288 | 289 | - **Type:** `Object` 290 | 291 | - **Default:** `undefined` 292 | 293 | - **Details:** 294 | 295 | The query sent to the API is constructed from this option. See the [Query API](../query) as well as [API](https://postgrest.org/en/latest/api.html) in the PostgREST documentation for more details. 296 | 297 | - **Example:** 298 | 299 | ``` js 300 | import { pg } from 'vue-postgrest' 301 | 302 | export default { 303 | name: 'Component', 304 | mixins: [pg], 305 | data () { 306 | return { 307 | pgConfig: { 308 | route: 'inhabitants', 309 | query: { 310 | select: ['id', 'name', 'address'], 311 | and: { 312 | 'name.not.eq': 'Tion Medon', 313 | 'city.eq': 'Pau City', 314 | 'age.gt': 150 315 | } 316 | } 317 | } 318 | } 319 | } 320 | } 321 | ``` 322 | 323 | ### single 324 | 325 | - **Type:** `Boolean` 326 | 327 | - **Default:** `false` 328 | 329 | - **Details:** 330 | 331 | If set to true, the request will be made with the `Accept: application/vnd.pgrst.object+json` header and `this.pg` will be of type [GenericModel](./#genericmodel). If set to false (the default), the header will be `Accept: application/json` and `this.pg` will be of type [GenericCollection](./#genericcollection). 332 | 333 | See also [Singular or Plural](https://postgrest.org/en/latest/api.html#singular-or-plural) in the PostgREST documentation. 334 | 335 | - **Example:** 336 | 337 | ``` js 338 | import { pg } from 'vue-postgrest' 339 | 340 | export default { 341 | name: 'Component', 342 | mixins: [pg], 343 | data () { 344 | return { 345 | pgConfig: { 346 | route: 'inhabitants', 347 | query: { 348 | 'id.eq': 1 349 | }, 350 | single: true 351 | } 352 | } 353 | } 354 | } 355 | ``` 356 | 357 | ### limit 358 | 359 | - **Type:** `Number` 360 | 361 | - **Default:** `undefined` 362 | 363 | - **Details:** 364 | 365 | Limits the count of response items by setting `Range-Unit` and `Range` headers. Only used when `single: false` is set. 366 | 367 | See also [Limits and Pagination](https://postgrest.org/en/latest/api.html#limits-and-pagination) in the PostgREST documentation. 368 | 369 | - **Example:** 370 | 371 | ``` js 372 | import { pg } from 'vue-postgrest' 373 | 374 | export default { 375 | name: 'Component', 376 | mixins: [pg], 377 | data () { 378 | return { 379 | pgConfig: { 380 | route: 'inhabitants', 381 | query: { 382 | 'age.gt': 150 383 | }, 384 | // get the first 10 inhabitants that pass the filter query 385 | limit: 10 386 | } 387 | } 388 | } 389 | } 390 | ``` 391 | 392 | 393 | ### offset 394 | 395 | - **Type:** `Number` 396 | 397 | - **Default:** `undefined` 398 | 399 | - **Details:** 400 | 401 | Offset the response items, useful e.g. for pagination, by setting `Range-Unit` and `Range` headers. Only used when `single: false` is set. 402 | 403 | See also [Limits and Pagination](https://postgrest.org/en/latest/api.html#limits-and-pagination) in the PostgREST documentation. 404 | 405 | - **Example:** 406 | 407 | ``` js 408 | import { pg } from 'vue-postgrest' 409 | 410 | export default { 411 | name: 'Component', 412 | mixins: [pg], 413 | data () { 414 | return { 415 | pgConfig: { 416 | route: 'inhabitants', 417 | query: { 418 | 'age.gt': 150 419 | }, 420 | // get all inhabitants that pass the filter query, starting from no. 5 421 | offset: 5 422 | } 423 | } 424 | } 425 | } 426 | ``` 427 | 428 | ### count 429 | 430 | - **Type:** `String` 431 | 432 | - **Default:** `undefined` 433 | 434 | - **Options:** 435 | 436 | - `exact` 437 | - `planned` 438 | - `estimated` 439 | 440 | - **Details:** 441 | 442 | Only used when `single: false` is set. 443 | 444 | See PostgREST docs for details on those options: 445 | 446 | - [Exact Count](https://postgrest.org/en/latest/api.html#exact-count) 447 | - [Planned Count](https://postgrest.org/en/latest/api.html#planned-count) 448 | - [Estimated Count](https://postgrest.org/en/latest/api.html#estimated-count) 449 | 450 | - **Example:** 451 | 452 | ``` js 453 | import { pg } from 'vue-postgrest' 454 | 455 | export default { 456 | name: 'Component', 457 | mixins: [pg], 458 | data () { 459 | return { 460 | pgConfig: { 461 | route: 'inhabitants', 462 | query: { 463 | 'age.gt': 150 464 | }, 465 | count: 'exact' 466 | } 467 | } 468 | } 469 | } 470 | ``` 471 | 472 | ## Mixin Hooks 473 | 474 | Hooks are called on the component instance that uses the `pg` mixin. 475 | 476 | ### onError 477 | 478 | - **Type:** `Function` 479 | 480 | - **Arguments:** 481 | 482 | - `{FetchError | AuthError} error` 483 | 484 | - **Details:** 485 | 486 | Called when a FetchError or AuthError occurs. The Hook gets passed the error object. 487 | 488 | - **Example:** 489 | 490 | ``` js 491 | import { pg } from 'vue-postgrest' 492 | 493 | export default { 494 | name: 'Component', 495 | mixins: [pg], 496 | data () { 497 | return { 498 | pgConfig: { 499 | route: 'inhabitants', 500 | query: {} 501 | } 502 | } 503 | }, 504 | onError (err) { 505 | // an error occured! 506 | console.log(err) 507 | } 508 | } 509 | ``` 510 | 511 | ## Mixin Properties 512 | 513 | Using the `pg` mixin exposes `this.pg` with the following properties. 514 | 515 | ### pg 516 | 517 | - **Type:** `GenericCollection | GenericModel` 518 | 519 | - **Details:** 520 | 521 | Dependent on the `pgConfig.single` setting this is either of type [GenericCollection](./#genericcollection) or [GenericModel](./#genericmodel). A GenericCollection is essentially just an Array of GenericModels with some additional methods. Both types have a `pg.$get()` method available to manually refresh the request. 522 | 523 | - **Example:** 524 | 525 | ``` js 526 | import { pg } from 'vue-postgrest' 527 | 528 | export default { 529 | name: 'Component', 530 | mixins: [pg], 531 | data () { 532 | return { 533 | pgConfig: { 534 | route: 'inhabitants', 535 | query: {} 536 | } 537 | } 538 | }, 539 | computed: { 540 | inhabitants () { 541 | return this.pg 542 | } 543 | } 544 | } 545 | ``` 546 | 547 | ## Instance Methods 548 | 549 | The instance method `vm.$postgrest` is available on your Vue Instance after installing the plugin. 550 | 551 | ### $postgrest 552 | 553 | - **Type:** `Route` 554 | 555 | - **Arguments:** 556 | 557 | - `{string} apiRoot` 558 | 559 | - `{string} token` 560 | 561 | - **Returns:** `Schema` 562 | 563 | - **Throws:** `SchemaNotFoundError` 564 | 565 | - **Usage:** 566 | 567 | Used to create a new schema for the specified baseUri with the specified default auth token. If `apiRoot` is undefined, the apiRoot of the existing Schema is used. 568 | 569 | ### $postgrest[route] 570 | 571 | - **Type:** `Route` 572 | 573 | - **Throws:** `AuthError | FetchError` 574 | 575 | - **Usage:** 576 | 577 | After the schema is [ready](./#postgrest-ready), all available routes are exposed on the $postgrest instance. 578 | The exposed `Route` accepts the following arguments: 579 | 580 | - `{string} method` one of `'OPTIONS'`, `'GET'`, `'HEAD'`, `'POST'`, `'PATCH'`, `'PUT'` or `'DELETE'` 581 | 582 | - `{object} query` see [Query](../query) 583 | 584 | - `{object} options` additional options, see below 585 | 586 | - `{object} body` payload for post/patch/put requests 587 | 588 | Available options are: 589 | 590 | - `{string} accept` `Accept` header to set or one of the options 'single', 'binary' or 'text', which set the header automatically. Default header is 'application/json'. 591 | 592 | - `{number} limit` Limit the response to no. of items by setting the `Range` and `Range-Unit` headers 593 | 594 | - `{number} offset` Offset the response by no. of items by setting the `Range` and `Range-Unit` headers 595 | 596 | - `{string} return` Set `return=[value]` part of `Prefer` header 597 | 598 | - `{string} params` Set `params=[value]` part of `Prefer` header 599 | 600 | - `{string} count` Set `count=[value]` part of `Prefer` header 601 | 602 | - `{string} resolution` Set `resolution=[value]` part of `Prefer` header 603 | 604 | - `{object} headers` Overwrite headers. Keys are header field names, values are strings. 605 | 606 | The `Route` instance provides convencience methods for calling the following HTTP requests directly, omit the `method` argument in this case: 607 | 608 | - `$postgrest.route.options([query, options])` 609 | 610 | - `$postgrest[route].get([query, options])` 611 | 612 | - `$postgrest[route].head([query, options])` 613 | 614 | - `$postgrest[route].post([query, options, body])` 615 | 616 | - `$postgrest[route].patch([query, options, body])` 617 | 618 | - `$postgrest[route].put([query, options, body])` 619 | 620 | - `$postgrest[route].delete([query, options])` 621 | 622 | - **Example:** 623 | 624 | ``` js 625 | export default { 626 | name: 'Galaxy', 627 | data () { 628 | return { 629 | planets: undefined, 630 | cities: undefined 631 | } 632 | } 633 | async mounted: { 634 | // wait for the schema to be ready 635 | await this.$postgrest.$ready 636 | const planetsResp = await this.$postgrest.planets('GET') 637 | const citiesResp = await this.$postgrest.cities.get() 638 | this.planets = await planetsResp.json() 639 | this.cities = await citiesResp.json() 640 | } 641 | } 642 | ``` 643 | 644 | ### $postgrest.$ready 645 | 646 | - **Type:** `Promise` 647 | 648 | - **Throws:** `SchemaNotFoundError` 649 | 650 | - **Usage:** 651 | 652 | The promise resolves, when the schema was successfully loaded and rejects if no valid schema was found. 653 | 654 | ::: tip 655 | This can also be called on a [route](./#postgrest-route) or a [rpc](./#postgrest-rpc). 656 | ::: 657 | 658 | - **Example:** 659 | 660 | ``` js 661 | export default { 662 | name: 'Component', 663 | async mounted: { 664 | // wait for the schema to be ready 665 | try { 666 | await this.$postgrest.$ready 667 | } catch (e) { 668 | console.log('Could not connect to API...') 669 | } 670 | } 671 | } 672 | ``` 673 | 674 | ### $postgrest.$route(route) 675 | 676 | - **Type:** `Function` 677 | 678 | - **Arguments:** 679 | 680 | - `{string} route` 681 | 682 | - **Returns:** `Route` 683 | 684 | - **Usage:** 685 | 686 | Use this function, if you have to access a route, before the schema is ready and the routes have been exposed on the $postgrest instance. Returns a `Route` for the specified route. 687 | 688 | - **Example:** 689 | 690 | ``` js 691 | export default { 692 | name: 'Cities', 693 | methods: { 694 | async getCities () { 695 | return this.$postgrest.$route('cities').get() 696 | }, 697 | async addCity () { 698 | await this.$postgrest.$route('cities').post({}, {}, { name: 'Galactic City' }) 699 | } 700 | } 701 | } 702 | ``` 703 | 704 | - **See also:** [$postgrest[route]](./#postgrest-route) 705 | 706 | ### $postgrest.rpc[function-name] 707 | 708 | - **Type:** `RPC` 709 | 710 | - **Usage:** 711 | 712 | After the schema is [ready](./#postgrest-ready), all available stored procedures are exposed on $postgrest.rpc[function-name] and can be called like this: `$postgrest.rpc[function-name]([params, options])`. 713 | 714 | The `params` object contains parameters that are passed to the stored procedure. 715 | 716 | Available `options` are: 717 | 718 | - `{boolean} get` set request method to 'GET' if true, otherwise 'POST' 719 | 720 | - `{string} accept` `Accept` header to set or one of the options 'single', 'binary' or 'text', which set the header automatically. Default header is 'application/json'. 721 | 722 | - `{object} headers` Properties of this object overwrite the specified header fields of the request. 723 | 724 | - **Example:** 725 | 726 | ``` js 727 | export default { 728 | name: 'Component', 729 | methods: { 730 | async destroyAllPlanets () { 731 | // wait till schema is loaded 732 | await this.$postgrest.$ready 733 | const result = await this.$postgrest.rpc.destroyplanets({ countdown: false }, { 734 | accept: 'text', 735 | headers: { 'Warning': 'Will cause problems!' } 736 | }) 737 | 738 | if (await result.text() !== 'all gone!') { 739 | this.$postgrest.rpc.destroyplanets({ force: true }) 740 | } 741 | } 742 | } 743 | } 744 | ``` 745 | 746 | ### $postgrest.rpc(function-name[, params, options]) 747 | 748 | - **Type:** `Function` 749 | 750 | - **Throws:** `AuthError | FetchError` 751 | 752 | - **Arguments:** 753 | 754 | - `{string} function-name` 755 | 756 | - `{object} params` 757 | 758 | - `{object} options` 759 | 760 | - **Returns:** API response 761 | 762 | - **Usage:** 763 | 764 | Calls a stored procedure on the API. `function-name` specifies the stored procedure to call. For `params` and `options` see [$postgrest.rpc](./#postgrest-rpc) 765 | 766 | - **Example:** 767 | 768 | ``` js 769 | export default { 770 | name: 'Component', 771 | methods: { 772 | async destroyAllPlanets () { 773 | await this.$postgrest.rpc('destroyplanets', { countdown: false }, { 774 | accept: 'text', 775 | headers: { 'Warning': 'Will cause problems!' } 776 | }) 777 | } 778 | } 779 | } 780 | ``` 781 | 782 | ## Component Props 783 | 784 | The `` component accepts all [mixin options](./#mixin-options) as props, see above for details. 785 | 786 | - **Example**: 787 | 788 | ``` html 789 | 796 | ``` 797 | 798 | ## Component Slot Scope 799 | 800 | The `` component provides the `pg` [mixin property](./#mixin-properties) as scope in the default slot, see above for details. 801 | 802 | - **Example**: 803 | 804 | ``` html 805 | 818 | ``` 819 | 820 | ## Component Events 821 | 822 | ### error 823 | 824 | - **Type:** `Event` 825 | 826 | - **Payload:** `AuthError | FetchError` 827 | 828 | - **Usage:** 829 | 830 | This event is emitted when an AuthError or FetchError occurs. 831 | 832 | - **Example:** 833 | 834 | ``` vue 835 | 849 | 850 | 866 | ``` 867 | 868 | ## GenericCollection 869 | 870 | A GenericCollection is essentially an Array of GenericModels and inherits all Array methods. The following additional methods and getters are available: 871 | 872 | ### $get([options]) 873 | 874 | - **Type:** `ObservableFunction` 875 | 876 | - **Arguments:** 877 | 878 | - `{object} options` 879 | 880 | - **Returns:** Response from the API 881 | 882 | - **Throws:** `AuthError | FetchError` 883 | 884 | - **Details:** 885 | 886 | An [ObservableFunction](./#observablefunction) for re-sending the get request. All Options described in [postgrest route](./#postgrest-route) are available here as well, except for the `accept` option. 887 | 888 | - **Example:** 889 | 890 | ``` js 891 | import { pg } from 'vue-postgrest' 892 | 893 | export default { 894 | name: 'Component', 895 | mixins: [pg], 896 | data () { 897 | return { 898 | pgConfig: { 899 | route: 'inhabitants', 900 | query: {} 901 | } 902 | } 903 | }, 904 | methods: { 905 | refresh () { 906 | this.pg.$get() 907 | if (this.pg.$get.isPending) { 908 | console.log('Get still pending...') 909 | } else { 910 | console.log('Fetched inhabitants: ', this.pg) 911 | } 912 | } 913 | } 914 | } 915 | ``` 916 | 917 | ### $new(data) 918 | 919 | - **Type:** `Function` 920 | 921 | - **Arguments:** 922 | 923 | - `{object} data` 924 | 925 | - **Returns:** `GenericModel` 926 | 927 | - **Details:** 928 | 929 | Creates and returns a new `GenericModel`, which can be used for a `$post()` call. 930 | 931 | - **Example:** 932 | 933 | ``` js 934 | import { pg } from 'vue-postgrest' 935 | 936 | export default { 937 | name: 'HeroesList', 938 | mixins: [pg], 939 | data () { 940 | return { 941 | newItem: null, 942 | pgConfig: { 943 | route: 'heroes' 944 | } 945 | } 946 | }, 947 | mounted () { 948 | this.newItem = this.pg.$new({ 949 | name: 'Yoda', 950 | age: 999999999 951 | }) 952 | }, 953 | methods: { 954 | addHero () { 955 | this.newItem.$post() 956 | } 957 | } 958 | } 959 | ``` 960 | 961 | ### $range 962 | 963 | - **Type:** `Object` 964 | 965 | - **Provided if:** API response sets `Content-Range` header 966 | 967 | - **Properties:** 968 | - `{number} first` first retrieved item 969 | 970 | - `{number} last` last retrieved item 971 | 972 | - `{number} totalCount` total number of retrieved items, undefined if `count` is not set 973 | 974 | - **Details:** 975 | 976 | An object describing the result of server-side pagination. 977 | 978 | - **Example:** 979 | 980 | ``` js 981 | import { pg } from 'vue-postgrest' 982 | 983 | export default { 984 | name: 'Component', 985 | mixins: [pg], 986 | data () { 987 | return { 988 | pgConfig: { 989 | route: 'inhabitants', 990 | query: { 991 | 'age.gt': 150 992 | }, 993 | offset: 5, 994 | limit: 10, 995 | count: 'estimated' 996 | } 997 | } 998 | }, 999 | computed: { 1000 | firstItem () { 1001 | // first retrieved item 1002 | return this.pg.$range.first 1003 | }, 1004 | lastItem () { 1005 | // last retrieved item 1006 | return this.pg.$range.last 1007 | }, 1008 | totalCount () { 1009 | // total number of retrieved items, undefined if option count is not set 1010 | return this.pg.$range.totalCount 1011 | } 1012 | } 1013 | } 1014 | ``` 1015 | 1016 | ## GenericModel 1017 | 1018 | The data of a GenericModel is available directly on the instance in addition to the following methods and getters: 1019 | 1020 | ### $get([options]) 1021 | 1022 | - **Type:** `ObservableFunction` 1023 | 1024 | - **Arguments:** 1025 | 1026 | - `{object} options` 1027 | 1028 | - **Returns:** Response from the API 1029 | 1030 | - **Throws:** `AuthError | FetchError | PrimaryKeyError` 1031 | 1032 | - **Details:** 1033 | 1034 | An [ObservableFunction](./#observablefunction) for a get request. Available `options` are: 1035 | 1036 | - `{boolean} keepChanges` If true, local changes to the model are protected from being overwritten by fetched data and only unchanged fields are updated. 1037 | 1038 | - All Options described in [postgrest route](./#postgrest-route) are available here as well. **Note:** The `accept` option is not valid here - the `Accept` header will always be set to `'single'` if not overwritten via the `headers` object. 1039 | 1040 | - **Example:** 1041 | 1042 | ``` js 1043 | import { pg } from 'vue-postgrest' 1044 | 1045 | export default { 1046 | name: 'UserProfile', 1047 | mixins: [pg], 1048 | data () { 1049 | return { 1050 | pgConfig: { 1051 | route: 'users', 1052 | query: { 1053 | 'id.eq': this.$store.getters.userId 1054 | }, 1055 | single: true 1056 | } 1057 | } 1058 | }, 1059 | methods: { 1060 | reloadUser () { 1061 | this.pg.$get() 1062 | } 1063 | } 1064 | } 1065 | ``` 1066 | 1067 | ### $post([options]) 1068 | 1069 | - **Type:** `ObservableFunction` 1070 | 1071 | - **Arguments:** 1072 | 1073 | - `{object} options` 1074 | 1075 | - **Returns:** Response from the API 1076 | 1077 | - **Throws:** `AuthError | FetchError` 1078 | 1079 | - **Details:** 1080 | 1081 | An [ObservableFunction](./#observablefunction) for a post request. Available `options` are: 1082 | 1083 | - `{array} columns` Sets `columns` parameter on request to improve performance on updates/inserts 1084 | 1085 | - `{string} return` Add `return=[value]` header to request. Possible values are `'representation'` (default) and `'minimal'`. 1086 | 1087 | - All Options described in [postgrest route](./#postgrest-route) are available here as well. **Note:** The `accept` option is not valid here - the `Accept` header will always be set to `'single'` if not overwritten via the `headers` object. 1088 | 1089 | If option `return` is set to `'representation'`, which is the default value, the model is updated with the response from the server. 1090 | 1091 | If option `return` is set to `'minimal'` and the `Location` header is set, the location header is returned as an object. 1092 | 1093 | - **Example:** 1094 | 1095 | ``` js 1096 | import { pg } from 'vue-postgrest' 1097 | 1098 | export default { 1099 | name: 'HeroesList', 1100 | mixins: [pg], 1101 | data () { 1102 | return { 1103 | newHero: null, 1104 | pgConfig: { 1105 | route: 'heroes' 1106 | } 1107 | } 1108 | }, 1109 | mounted () { 1110 | this.newHero = this.pg.$new({ 1111 | name: 'Yoda', 1112 | age: 999999999 1113 | }) 1114 | }, 1115 | methods: { 1116 | addHero () { 1117 | this.newHero.$post() 1118 | } 1119 | } 1120 | } 1121 | ``` 1122 | 1123 | ### $put([options]) 1124 | 1125 | - **Type:** `ObservableFunction` 1126 | 1127 | - **Arguments:** 1128 | 1129 | - `{object} options` 1130 | 1131 | - **Returns:** Response from the API 1132 | 1133 | - **Throws:** `AuthError | FetchError | PrimaryKeyError` 1134 | 1135 | - **Details:** 1136 | 1137 | An [ObservableFunction](./#observablefunction) for a put request. Available `options` are: 1138 | 1139 | - `{array} columns` Sets `columns` parameter on request to improve performance on updates/inserts 1140 | 1141 | - `{string} return` Add `return=[value]` header to request. Possible values are `'representation'` (default) and `'minimal'`. 1142 | 1143 | - All Options described in [postgrest route](./#postgrest-route) are available here as well. **Note:** The `accept` option is not valid here - the `Accept` header will always be set to `'single'` if not overwritten via the `headers` object. 1144 | 1145 | If option `return` is set to `'representation'`, which is the default value, the model is updated with the response from the server. 1146 | 1147 | If option `return` is set to `'minimal'` and the `Location` header is set, the location header is returned as an object. 1148 | 1149 | - **Example:** 1150 | 1151 | ``` js 1152 | import { pg } from 'vue-postgrest' 1153 | 1154 | export default { 1155 | name: 'HeroesList', 1156 | mixins: [pg], 1157 | data () { 1158 | return { 1159 | newHero: null, 1160 | pgConfig: { 1161 | route: 'heroes' 1162 | } 1163 | } 1164 | }, 1165 | mounted () { 1166 | this.newHero = this.pg.$new({ 1167 | name: 'Yoda', 1168 | age: 999999999 1169 | }) 1170 | }, 1171 | methods: { 1172 | upsertHero () { 1173 | // Assuming "name" is the primary key, because PUT needs a PK set 1174 | this.newHero.$put() 1175 | } 1176 | } 1177 | } 1178 | ``` 1179 | 1180 | ### $patch([options, data]) 1181 | 1182 | - **Type:** `ObservableFunction` 1183 | 1184 | - **Arguments:** 1185 | 1186 | - `{object} options` 1187 | 1188 | - `{object} data` 1189 | 1190 | - **Returns:** Response from the API 1191 | 1192 | - **Throws:** `AuthError | FetchError | PrimaryKeyError` 1193 | 1194 | - **Details:** 1195 | 1196 | An [ObservableFunction](./#observablefunction) for a patch request. The patch function also accepts an object as first argument with fields that should be patched, properties declared in this object take precedence over fields changed on the model directly. Available `options` are: 1197 | 1198 | - `{array} columns` Sets `columns` parameter on request to improve performance on updates/inserts 1199 | 1200 | - `{string} return` Add `return=[value]` header to request. Possible values are `'representation'` (default) and `'minimal'`. 1201 | 1202 | - All Options described in [postgrest route](./#postgrest-route) are available here as well. **Note:** The `accept` option is not valid here - the `Accept` header will always be set to `'single'` if not overwritten via the `headers` object. 1203 | 1204 | If option `return` is set to `'representation'`, which is the default value, the model is updated with the response from the server. 1205 | 1206 | - **Example:** 1207 | 1208 | ``` js 1209 | import { pg } from 'vue-postgrest' 1210 | 1211 | export default { 1212 | name: 'HeroProfile', 1213 | mixins: [pg], 1214 | data () { 1215 | return { 1216 | pgConfig: { 1217 | route: 'heroes', 1218 | query: { 1219 | 'name.eq': 'Yoda' 1220 | }, 1221 | accept: 'single' 1222 | } 1223 | } 1224 | }, 1225 | methods: { 1226 | updateHeroAge (age) { 1227 | this.pg.age = age 1228 | this.pg.$patch({}, { name: 'Younger Yoda '}) 1229 | // sends a patch request with the data: { age: age, name: 'Younger Yoda' } 1230 | } 1231 | } 1232 | } 1233 | ``` 1234 | 1235 | ### $delete([options]) 1236 | 1237 | - **Type:** `ObservableFunction` 1238 | 1239 | - **Arguments:** 1240 | 1241 | - `{object} options` 1242 | 1243 | - **Returns:** Response from the API 1244 | 1245 | - **Throws:** `AuthError | FetchError | PrimaryKeyError` 1246 | 1247 | - **Details:** 1248 | 1249 | An [ObservableFunction](./#observablefunction) for a delete request. Available `options` are: 1250 | 1251 | - `{string} return` Add `return=[value]` header to request. Possible values are `'representation'` and `'minimal'`. 1252 | 1253 | - All Options described in [postgrest route](./#postgrest-route) are available here as well. **Note:** The `accept` option is not valid here - the `Accept` header will always be set to `'single'` if not overwritten via the `headers` object. 1254 | 1255 | If option `return` is set to `'representation'`, the model is updated with the response from the server. 1256 | 1257 | - **Example:** 1258 | 1259 | ``` js 1260 | import { pg } from 'vue-postgrest' 1261 | 1262 | export default { 1263 | name: 'HeroProfile', 1264 | mixins: [pg], 1265 | data () { 1266 | return { 1267 | pgConfig: { 1268 | route: 'heroes', 1269 | query: { 1270 | 'name.eq': 'Yoda' 1271 | }, 1272 | single: true 1273 | } 1274 | } 1275 | }, 1276 | methods: { 1277 | deleteYoda () { 1278 | // oh, no! 1279 | this.pg.$delete() 1280 | } 1281 | } 1282 | } 1283 | ``` 1284 | 1285 | ### $isDirty 1286 | 1287 | - **Type:** `Boolean` 1288 | 1289 | - **Details:** 1290 | 1291 | Indicating whether the model data has changed from its initial state. 1292 | 1293 | - **Example:** 1294 | 1295 | ``` js 1296 | import { pg } from 'vue-postgrest' 1297 | 1298 | export default { 1299 | name: 'HeroProfile', 1300 | mixins: [pg], 1301 | data () { 1302 | return { 1303 | pgConfig: { 1304 | route: 'heroes', 1305 | query: { 1306 | 'name.eq': 'Yoda' 1307 | }, 1308 | single: true 1309 | } 1310 | } 1311 | }, 1312 | methods: { 1313 | updateHero () { 1314 | if (this.pg.$isDirty) { 1315 | this.pg.$patch() 1316 | } 1317 | } 1318 | } 1319 | } 1320 | ``` 1321 | 1322 | ### $reset() 1323 | 1324 | - **Type:** `Function` 1325 | 1326 | - **Details:** 1327 | 1328 | Reset the model data to it's initial state. 1329 | 1330 | - **Example:** 1331 | 1332 | ``` js 1333 | import { pg } from 'vue-postgrest' 1334 | 1335 | export default { 1336 | name: 'HeroProfile', 1337 | mixins: [pg], 1338 | data () { 1339 | return { 1340 | pgConfig: { 1341 | route: 'heroes', 1342 | query: { 1343 | 'name.eq': 'Yoda' 1344 | }, 1345 | accept: 'single' 1346 | } 1347 | } 1348 | }, 1349 | methods: { 1350 | changeAge (age) { 1351 | this.pg.age = age 1352 | }, 1353 | resetHero () { 1354 | this.pg.$reset() 1355 | } 1356 | } 1357 | } 1358 | ``` 1359 | 1360 | ## ObservableFunction 1361 | 1362 | An ObservableFunction has the following Vue-reactive properties indicating it's current status. 1363 | 1364 | ### clear([error|index, ...]) 1365 | 1366 | - **Type:** `Function` 1367 | 1368 | - **Arguments:** 1369 | 1370 | - any number of type `Error` or `Number` 1371 | 1372 | - **Returns:** Nothing 1373 | 1374 | - **Details:** 1375 | 1376 | Removes errors from the `.errors` property. `clear(error, ...)` removes specific errors by reference. `clear(index, ...)` removes specific errors by index. `clear()` removes all errors and resets `.hasReturned`. 1377 | 1378 | ``` javascript 1379 | try { 1380 | this.pg.$delete() 1381 | } catch (e) { 1382 | if (e instanceof AuthError) { 1383 | this.handleAuthError() 1384 | this.pg.$delete.clear(e) 1385 | } else { 1386 | // error e.g. rendered in template 1387 | } 1388 | } 1389 | ``` 1390 | 1391 | ### errors 1392 | 1393 | - **Type:** `Array` 1394 | 1395 | - **Details:** 1396 | 1397 | An Array of Errors that are associated with this Function. This is cleared automatically upon the next successful request or manually with `ObservableFunction.clear()`. 1398 | 1399 | ### hasError 1400 | 1401 | - **Type:** `Boolean` 1402 | 1403 | - **Details:** 1404 | 1405 | Indicating whether there were errors during the request. This is cleared automatically upon the next successful request or manually with `ObservableFunction.clear()`. 1406 | 1407 | ### hasReturned 1408 | 1409 | - **Type:** `Boolean` 1410 | 1411 | - **Details:** 1412 | 1413 | Indicating whether the request has returned successfully at least once. Useful to differentiate between "first load" and "refresh" in conjunction with `ObservableFunction.isPending`. This can be reset to false manually by calling `ObservableFunction.clear()` without arguments. 1414 | 1415 | ### isPending 1416 | 1417 | - **Type:** `Boolean` 1418 | 1419 | - **Details:** 1420 | 1421 | Indicating whether there are pending calls for this Function. 1422 | 1423 | ### pending 1424 | 1425 | - **Type:** `Array` 1426 | 1427 | - **Details:** 1428 | 1429 | This array holds an `AbortController` instance for every function call that is currently pending. Those are passed to the underlying `fetch()` call for requests and can be used to cancel the request. See [AbortController (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) for details. 1430 | --------------------------------------------------------------------------------