├── .husky ├── .gitignore └── pre-commit ├── www ├── docs │ └── .gitignore ├── static │ └── img │ │ ├── favicon.png │ │ ├── favicon │ │ └── favicon.ico │ │ ├── shyft-logo-white-small.svg │ │ ├── shyft-logo-white.svg │ │ └── shyft-logo.svg ├── babel.config.js ├── Dockerfile ├── sidebars.js ├── .gitignore ├── src │ ├── components │ │ └── HomepageFeatures.module.css │ ├── pages │ │ ├── intro.js │ │ ├── index.module.css │ │ └── index.js │ └── css │ │ └── custom.css ├── docker-compose.yml ├── README.md ├── package.json └── docusaurus.config.js ├── .graphqlrc ├── renovate.json ├── .eslintignore ├── storageScripts ├── trigram_extension.tpl.sql ├── i18n_text_index.tpl.sql ├── i18n_trgm_index.tpl.sql ├── get_state_map.tpl.sql ├── get_state_id.tpl.sql ├── get_state_name.tpl.sql ├── get_attribute_translation.tpl.sql ├── get_state_ids.tpl.sql ├── merge_translations.tpl.sql └── get_attribute_translations.tpl.sql ├── test ├── generateData.ts ├── setupAndTearDown.ts ├── models │ ├── ClusterZone.ts │ ├── Server.ts │ ├── Tag.ts │ ├── WebsiteTag.ts │ ├── BoardMemberView.ts │ ├── Book.ts │ ├── Message.ts │ ├── DataTypeTester.ts │ ├── Profile.ts │ ├── Board.ts │ ├── Website.ts │ └── BoardMember.ts ├── __snapshots__ │ ├── integration.spec.ts.snap │ └── storageDataTypes.spec.ts.snap ├── data │ ├── books.csv │ ├── boards.csv │ ├── invites.csv │ ├── profiles.csv │ └── messages.csv ├── testingData.ts ├── storageDataTypes.spec.ts ├── integration.spec.ts ├── testUtils.ts └── testSetGenerator.ts ├── .prettierrc ├── src ├── engine │ ├── datatype │ │ ├── ComplexDataType.ts │ │ ├── __snapshots__ │ │ │ ├── DataTypeUser.spec.ts.snap │ │ │ ├── DataType.spec.ts.snap │ │ │ ├── DataTypeEnum.spec.ts.snap │ │ │ ├── DataTypeState.spec.ts.snap │ │ │ ├── ListDataType.spec.ts.snap │ │ │ └── ObjectDataType.spec.ts.snap │ │ ├── DataTypeUser.ts │ │ ├── DataTypeUser.spec.ts │ │ ├── DataTypeState.ts │ │ ├── DataTypeEnum.ts │ │ ├── DataType.spec.ts │ │ ├── DataType.ts │ │ └── dataTypes.ts │ ├── context │ │ └── Context.ts │ ├── CustomError.ts │ ├── models │ │ ├── Language.ts │ │ └── User.ts │ ├── i18n.ts │ ├── storage │ │ ├── StorageTypeNull.ts │ │ ├── __snapshots__ │ │ │ ├── StorageDataType.spec.ts.snap │ │ │ └── StorageType.spec.ts.snap │ │ └── StorageDataType.ts │ ├── schema │ │ ├── __snapshots__ │ │ │ └── Schema.spec.ts.snap │ │ └── Schema.spec.ts │ ├── entity │ │ └── __snapshots__ │ │ │ ├── ShadowEntity.spec.ts.snap │ │ │ └── ViewEntity.spec.ts.snap │ ├── __snapshots__ │ │ ├── util.spec.ts.snap │ │ ├── cursor.spec.ts.snap │ │ ├── validation.spec.ts.snap │ │ └── filter.spec.ts.snap │ ├── protocol │ │ ├── __snapshots__ │ │ │ └── ProtocolType.spec.ts.snap │ │ └── ProtocolConfiguration.ts │ ├── index │ │ ├── __snapshots__ │ │ │ └── Index.spec.ts.snap │ │ └── Index.ts │ ├── constants.ts │ ├── helpers.ts │ └── configuration │ │ └── Configuration.ts ├── graphqlProtocol │ ├── protocolGraphqlConstants.ts │ ├── graphRegistry.ts │ ├── generator.spec.ts │ ├── types.ts │ ├── index.ts │ ├── __snapshots__ │ │ ├── dataTypes.spec.ts.snap │ │ ├── filter.spec.ts.snap │ │ └── generator.spec.ts.snap │ ├── util.spec.ts │ ├── registry.ts │ ├── helper.ts │ ├── sort.ts │ ├── dataTypes.spec.ts │ └── query.ts └── storage-connector │ ├── __snapshots__ │ └── util.spec.ts.snap │ ├── index.ts │ ├── util.ts │ ├── permission.ts │ ├── util.spec.ts │ ├── helpers.ts │ └── i18n.ts ├── .editorconfig ├── tsconfig.json ├── jest.config.js ├── .vscode └── settings.json ├── codecov.yml ├── .gitignore ├── LICENSE ├── .travis.yml ├── .github └── workflows │ ├── main.yml │ └── release.yml ├── README.md ├── misc └── shyft-logo.svg ├── package.json └── .eslintrc.js /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /www/docs/.gitignore: -------------------------------------------------------------------------------- 1 | api 2 | -------------------------------------------------------------------------------- /.graphqlrc: -------------------------------------------------------------------------------- 1 | schema: 'schema.graphql' 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | lib 4 | www/typedoc-sidebars.json 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /storageScripts/trigram_extension.tpl.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE EXTENSION IF NOT EXISTS pg_trgm; 3 | -------------------------------------------------------------------------------- /test/generateData.ts: -------------------------------------------------------------------------------- 1 | import { generateMockData } from './testSetGenerator'; 2 | 3 | generateMockData(); 4 | -------------------------------------------------------------------------------- /www/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriskalmar/shyft/HEAD/www/static/img/favicon.png -------------------------------------------------------------------------------- /www/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /www/static/img/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriskalmar/shyft/HEAD/www/static/img/favicon/favicon.ico -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 80, 5 | "bracketSpacing": true 6 | } 7 | -------------------------------------------------------------------------------- /storageScripts/i18n_text_index.tpl.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX <%= indexName %> ON <%= storageTableName %> ((i18n->'<%= attributeName %>'->>'<%= languageId %>') text_pattern_ops); 2 | -------------------------------------------------------------------------------- /storageScripts/i18n_trgm_index.tpl.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX <%= indexName %> ON <%= storageTableName %> USING GIN ((i18n->'<%= attributeName %>'->>'<%= languageId %>') gin_trgm_ops); 2 | -------------------------------------------------------------------------------- /src/engine/datatype/ComplexDataType.ts: -------------------------------------------------------------------------------- 1 | export class ComplexDataType {} 2 | 3 | export const isComplexDataType = (obj: unknown): obj is ComplexDataType => { 4 | return obj instanceof ComplexDataType; 5 | }; 6 | -------------------------------------------------------------------------------- /www/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.16.1 2 | 3 | WORKDIR /app/website 4 | 5 | EXPOSE 3000 35729 6 | COPY ./docs /app/docs 7 | COPY ./website /app/website 8 | RUN yarn install 9 | 10 | CMD ["yarn", "start"] 11 | -------------------------------------------------------------------------------- /src/graphqlProtocol/protocolGraphqlConstants.ts: -------------------------------------------------------------------------------- 1 | // use this data field to tell node definitions which type to return 2 | export const RELAY_TYPE_PROMOTER_FIELD = '_type_'; 3 | 4 | export const MAX_PAGE_SIZE = 100; 5 | -------------------------------------------------------------------------------- /src/engine/datatype/__snapshots__/DataTypeUser.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`DataTypeUser isDataTypeUser should recognize non-DataTypeUser objects 1`] = `"Not a DataType object"`; 4 | -------------------------------------------------------------------------------- /www/sidebars.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mySidebar: [ 3 | { 4 | type: 'autogenerated', 5 | dirName: '.', // generate sidebar slice from the docs folder (or versioned_docs/) 6 | }, 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /src/engine/context/Context.ts: -------------------------------------------------------------------------------- 1 | export interface Context { 2 | userId?: string | number; 3 | userRoles?: string[]; 4 | loaders?: Record; 5 | i18nLanguage?: string; 6 | custom?: Record; 7 | } 8 | -------------------------------------------------------------------------------- /src/engine/datatype/DataTypeUser.ts: -------------------------------------------------------------------------------- 1 | import { DataType } from './DataType'; 2 | 3 | export class DataTypeUser extends DataType {} 4 | 5 | export const isDataTypeUser = (obj: unknown): obj is DataTypeUser => { 6 | return obj instanceof DataTypeUser; 7 | }; 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | end_of_line = lf 8 | 9 | [*.{js,json}] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.{md,markdown}] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /test/setupAndTearDown.ts: -------------------------------------------------------------------------------- 1 | import { initDB, disconnectDB, initGraphQLSchema } from './db'; 2 | import { loadData } from './loadData'; 3 | 4 | beforeAll(async () => { 5 | await initDB(); 6 | initGraphQLSchema(); 7 | await loadData(); 8 | }); 9 | 10 | afterAll(() => { 11 | return disconnectDB(); 12 | }); 13 | -------------------------------------------------------------------------------- /www/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | /typedoc-sidebar.js 19 | -------------------------------------------------------------------------------- /test/models/ClusterZone.ts: -------------------------------------------------------------------------------- 1 | import { Entity, DataTypeString } from '../../src'; 2 | 3 | export const ClusterZone = new Entity({ 4 | name: 'ClusterZone', 5 | description: 'A server cluster zone', 6 | 7 | attributes: { 8 | name: { 9 | type: DataTypeString, 10 | description: 'Cluster zone name', 11 | required: true, 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /www/src/components/HomepageFeatures.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | .features { 4 | display: flex; 5 | align-items: center; 6 | padding: 2rem 0; 7 | width: 100%; 8 | } 9 | 10 | .featureSvg { 11 | height: 200px; 12 | width: 200px; 13 | } 14 | 15 | .attribution { 16 | font-size: 13px; 17 | text-align: right; 18 | } 19 | 20 | .attribution a:hover { 21 | text-decoration: none; 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib", 4 | "allowJs": false, 5 | "declaration": true, 6 | "target": "es5", 7 | "noUnusedLocals": false, 8 | "noUnusedParameters": true, 9 | "typeRoots": ["./node_modules/@types"], 10 | "lib": ["es2017", "esnext.asynciterable"], 11 | "esModuleInterop": true, 12 | }, 13 | "include": ["./src/**/*"], 14 | "exclude": ["node_modules", "./lib/**/*" , "**/*.spec.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src', '/test'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | '^.+\\.js$': 'babel-jest', 6 | }, 7 | globals: { 8 | 'ts-jest': { 9 | diagnostics: false, 10 | }, 11 | }, 12 | // transformIgnorePatterns: [ '/node_modules/', '^.+\\.js?$' ], 13 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js|tsx?)$', 14 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 15 | }; 16 | -------------------------------------------------------------------------------- /src/engine/datatype/__snapshots__/DataType.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`DataType isDataType should recognize non-DataType objects 1`] = `"Not a DataType object"`; 4 | 5 | exports[`DataType should have a description 1`] = `"Missing description for data type 'example'"`; 6 | 7 | exports[`DataType should have a name 1`] = `"Missing data type name"`; 8 | 9 | exports[`DataType should provide a mock function 1`] = `"'Missing mock function for data type 'example'"`; 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "files.trimTrailingWhitespace": true, 4 | "files.insertFinalNewline": true, 5 | "search.exclude": { 6 | "lib": true 7 | }, 8 | "editor.formatOnSave": true, 9 | "[javascript]": { 10 | "editor.formatOnSave": true 11 | }, 12 | "[typescript]": { 13 | "editor.formatOnSave": true 14 | }, 15 | "eslint.validate": ["javascript"], 16 | "workbench.colorCustomizations": { 17 | "activityBar.background": "#1c3350" 18 | }, 19 | "editor.codeActionsOnSave": { 20 | "source.fixAll.eslint": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /www/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | docusaurus: 5 | build: . 6 | ports: 7 | - 3000:3000 8 | - 35729:35729 9 | volumes: 10 | - ./docs:/app/docs 11 | - ./website/blog:/app/website/blog 12 | - ./website/core:/app/website/core 13 | - ./website/i18n:/app/website/i18n 14 | - ./website/pages:/app/website/pages 15 | - ./website/static:/app/website/static 16 | - ./website/sidebars.json:/app/website/sidebars.json 17 | - ./website/siteConfig.js:/app/website/siteConfig.js 18 | working_dir: /app/website 19 | -------------------------------------------------------------------------------- /storageScripts/get_state_map.tpl.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE OR REPLACE FUNCTION <%= functionName %>( 3 | entity TEXT 4 | ) RETURNS JSON AS $$ 5 | DECLARE 6 | statesMap JSON; 7 | BEGIN 8 | 9 | -- This is an auto-generated function 10 | -- Template (get_state_map.tpl.sql) 11 | 12 | statesMap := '<%= statesMap %>'; 13 | 14 | IF (statesMap->entity IS NOT NULL) THEN 15 | RETURN statesMap->entity; 16 | ELSE 17 | RAISE EXCEPTION 'Unknown entity name used in <%= functionName %>(): %', entity; 18 | END IF; 19 | 20 | RETURN result; 21 | END; 22 | $$ 23 | LANGUAGE plpgsql STRICT IMMUTABLE; 24 | -------------------------------------------------------------------------------- /www/src/pages/intro.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires,import/no-unresolved */ 2 | import React from 'react'; 3 | import Layout from '@theme/Layout'; 4 | import Head from '@docusaurus/Head'; 5 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 6 | 7 | export default function Intro() { 8 | const siteConfig = useDocusaurusContext(); 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | </Head> 16 | </Layout> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/graphqlProtocol/graphRegistry.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType } from 'graphql'; 2 | import { Action, Entity, ViewEntity } from '..'; 3 | 4 | export interface GraphRegistryType { 5 | types: { 6 | [key: string]: { 7 | entity: Entity | ViewEntity; 8 | type: GraphQLObjectType; 9 | connection?: GraphQLObjectType; 10 | connectionArgs?: unknown; 11 | }; 12 | }; 13 | actions: { 14 | [key: string]: { 15 | action: Action; 16 | }; 17 | }; 18 | } 19 | 20 | // collect object types, connections ... for each entity 21 | export const graphRegistry: GraphRegistryType = { 22 | types: {}, 23 | actions: {}, 24 | }; 25 | -------------------------------------------------------------------------------- /test/models/Server.ts: -------------------------------------------------------------------------------- 1 | import { Entity, DataTypeString } from '../../src'; 2 | 3 | import { ClusterZone } from './ClusterZone'; 4 | 5 | export const Server = new Entity({ 6 | name: 'Server', 7 | description: 'A server', 8 | 9 | attributes: { 10 | clusterZone: { 11 | type: ClusterZone, 12 | description: 'Server cluster zone', 13 | required: true, 14 | }, 15 | 16 | name: { 17 | type: DataTypeString, 18 | description: 'Server name', 19 | required: true, 20 | }, 21 | 22 | ip: { 23 | type: DataTypeString, 24 | description: 'IP of server', 25 | required: true, 26 | }, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /src/engine/CustomError.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | declare type ErrorInterface = Error; 4 | 5 | declare class Error implements ErrorInterface { 6 | name: string; 7 | message: string; 8 | static captureStackTrace(object: any, objectConstructor?: any): any; 9 | } 10 | 11 | export class CustomError extends Error { 12 | code: any; 13 | status?: any; 14 | meta?: any; 15 | 16 | constructor(message?: string, code?: any, status?: any, meta?: any) { 17 | // super(message); 18 | super(); 19 | Error.captureStackTrace(this, this.constructor); 20 | this.message = message; 21 | this.name = this.constructor.name; 22 | this.code = code; 23 | this.status = status; 24 | this.meta = meta; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/graphqlProtocol/generator.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | 3 | import { generateGraphQLSchema } from './generator'; 4 | import { generateTestSchema } from './test-helper'; 5 | 6 | let configuration; 7 | 8 | beforeAll(async () => { 9 | const setup = await generateTestSchema(); 10 | configuration = setup.configuration; 11 | }); 12 | 13 | describe('generator', () => { 14 | describe('test', () => { 15 | it('should render GraphQL Schema', () => { 16 | const graphqlSchema = generateGraphQLSchema(configuration); 17 | expect(typeof graphqlSchema).toEqual('object'); 18 | // console.log('graphqlSchema', { graphqlSchema }); 19 | expect(graphqlSchema).toMatchSnapshot(); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /storageScripts/get_state_id.tpl.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE OR REPLACE FUNCTION <%= functionName %>( 3 | entity TEXT, 4 | state_name TEXT 5 | ) RETURNS INTEGER AS $$ 6 | DECLARE 7 | statesMap JSON; 8 | BEGIN 9 | 10 | -- This is an auto-generated function 11 | -- Template (get_state_id.tpl.sql) 12 | 13 | statesMap := '<%= statesMap %>'; 14 | 15 | IF (statesMap->entity IS NOT NULL) THEN 16 | IF (statesMap->entity->state_name IS NOT NULL) THEN 17 | RETURN statesMap->entity->state_name; 18 | ELSE 19 | RAISE EXCEPTION 'Unknown state name used in <%= functionName %>(): %.%', entity, state_name; 20 | END IF; 21 | ELSE 22 | RAISE EXCEPTION 'Unknown entity name used in <%= functionName %>(): %', entity; 23 | END IF; 24 | 25 | END; 26 | $$ 27 | LANGUAGE plpgsql STRICT IMMUTABLE; 28 | -------------------------------------------------------------------------------- /storageScripts/get_state_name.tpl.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE OR REPLACE FUNCTION <%= functionName %>( 3 | entity TEXT, 4 | state_id INTEGER 5 | ) RETURNS TEXT AS $$ 6 | DECLARE 7 | statesMap JSON; 8 | BEGIN 9 | 10 | -- This is an auto-generated function 11 | -- Template (get_state_name.tpl.sql) 12 | 13 | statesMap := '<%= statesMapFlipped %>'; 14 | 15 | IF (statesMap->entity IS NOT NULL) THEN 16 | IF (statesMap->entity->state_id::TEXT IS NOT NULL) THEN 17 | RETURN statesMap->entity->>state_id::TEXT; 18 | ELSE 19 | RAISE EXCEPTION 'Unknown state id used in <%= functionName %>(): %.%', entity, state_id; 20 | END IF; 21 | ELSE 22 | RAISE EXCEPTION 'Unknown entity name used in <%= functionName %>(): %', entity; 23 | END IF; 24 | 25 | END; 26 | $$ 27 | LANGUAGE plpgsql STRICT IMMUTABLE; 28 | -------------------------------------------------------------------------------- /src/graphqlProtocol/types.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLScalarType, GraphQLType } from 'graphql'; 2 | 3 | // GraphQLFieldConfig ? 4 | export type DataOutputField = { 5 | description: string; 6 | type: GraphQLType; 7 | resolve?: Function; 8 | }; 9 | 10 | // // GraphQLOutputFieldConfigMap 11 | export type WrappedDataOutputField = { 12 | [fieldName: string]: DataOutputField; 13 | }; 14 | 15 | export type InputFields = { 16 | clientMutationId?: { type: GraphQLScalarType }; 17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 | data?: any; 19 | }; 20 | 21 | export type OutputFields = { 22 | clientMutationId?: { type: GraphQLScalarType }; 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | result?: any; 25 | }; 26 | 27 | export type ConnectionNode = { 28 | cursor?: any; 29 | node?: any; 30 | }; 31 | -------------------------------------------------------------------------------- /www/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ## Installation 6 | 7 | ```console 8 | yarn install 9 | ``` 10 | 11 | ## Local Development 12 | 13 | ```console 14 | yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ## Build 20 | 21 | ```console 22 | yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ## Deployment 28 | 29 | ```console 30 | GIT_USER=<Your GitHub username> USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /test/__snapshots__/integration.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`postgres should generated indices: indexList 1`] = ` 4 | Array [ 5 | Object { 6 | "indexname": "PK_865a0f2e22c140d261b1df80eb1", 7 | "unique": true, 8 | }, 9 | Object { 10 | "indexname": "board_created_by_117dbb19db_key", 11 | "unique": false, 12 | }, 13 | Object { 14 | "indexname": "board_is_private_e34a1f4ba1_key", 15 | "unique": false, 16 | }, 17 | Object { 18 | "indexname": "board_name_82a3537ff0_key", 19 | "unique": true, 20 | }, 21 | Object { 22 | "indexname": "board_owner_4c1029697e_key", 23 | "unique": false, 24 | }, 25 | Object { 26 | "indexname": "board_updated_by_d9a2ae5845_key", 27 | "unique": false, 28 | }, 29 | Object { 30 | "indexname": "board_vip_f9505a739a_key", 31 | "unique": true, 32 | }, 33 | ] 34 | `; 35 | -------------------------------------------------------------------------------- /test/models/Tag.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | DataTypeString, 4 | Index, 5 | INDEX_UNIQUE, 6 | Permission, 7 | } from '../../src'; 8 | import { Language } from '../../src/engine/models/Language'; 9 | 10 | const readPermissions = [Permission.EVERYONE]; 11 | 12 | export const Tag = new Entity({ 13 | name: 'Tag', 14 | description: 'A tag', 15 | 16 | indexes: [ 17 | new Index({ 18 | type: INDEX_UNIQUE, 19 | attributes: ['name', 'language'], 20 | }), 21 | ], 22 | 23 | permissions: { 24 | read: readPermissions, 25 | mutations: { 26 | create: new Permission().role('admin'), 27 | }, 28 | }, 29 | 30 | attributes: { 31 | name: { 32 | type: DataTypeString, 33 | description: 'Tag name', 34 | required: true, 35 | }, 36 | 37 | language: { 38 | type: Language, 39 | description: 'Tag language', 40 | required: true, 41 | }, 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /test/models/WebsiteTag.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Index, INDEX_UNIQUE, Permission } from '../../src'; 2 | import { Tag } from './Tag'; 3 | import { Website } from './Website'; 4 | 5 | const readPermissions = [Permission.EVERYONE]; 6 | 7 | export const WebsiteTag = new Entity({ 8 | name: 'WebsiteTag', 9 | description: 'A website / tag mapping', 10 | 11 | includeUserTracking: true, 12 | 13 | indexes: [ 14 | new Index({ 15 | type: INDEX_UNIQUE, 16 | attributes: ['tag', 'website'], 17 | }), 18 | ], 19 | 20 | permissions: { 21 | read: readPermissions, 22 | mutations: { 23 | create: new Permission().authenticated(), 24 | }, 25 | }, 26 | 27 | attributes: { 28 | website: { 29 | type: Website, 30 | description: 'a website', 31 | required: true, 32 | }, 33 | 34 | tag: { 35 | type: Tag, 36 | description: 'a tag', 37 | required: true, 38 | }, 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /src/storage-connector/__snapshots__/util.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`util generateIndexName should generate index names 1`] = `"some_entity_first_name_last_name_44c3ffd0e4__idx"`; 4 | 5 | exports[`util generateIndexName should generate index with custom suffix 1`] = `"some_entity_last_name_first_name_44c3ffd0e4_my_suffix"`; 6 | 7 | exports[`util generateIndexName should generate unique index names based on attribute order 1`] = `"some_entity_last_name_first_name_44c3ffd0e4__idx"`; 8 | 9 | exports[`util quote should quote attributes 1`] = `"\\"someAttributeName\\""`; 10 | 11 | exports[`util quote should quote attributes with JSON pointers 1`] = `"\\"someAttributeName\\"->'someProp'"`; 12 | 13 | exports[`util quote should quote fully qualified attributes 1`] = `"someEntity.\\"someAttributeName\\""`; 14 | 15 | exports[`util quote should quote fully qualified attributes with JSON pointers 1`] = `"someEntity.\\"someAttributeName\\"->'someProp'"`; 16 | -------------------------------------------------------------------------------- /src/engine/models/Language.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '../entity/Entity'; 2 | import { DataTypeString } from '../datatype/dataTypes'; 3 | import { Index, INDEX_UNIQUE } from '../index/Index'; 4 | 5 | export const Language = new Entity({ 6 | name: 'Language', 7 | description: 'A language', 8 | 9 | indexes: [ 10 | new Index({ 11 | type: INDEX_UNIQUE, 12 | attributes: ['name'], 13 | }), 14 | new Index({ 15 | type: INDEX_UNIQUE, 16 | attributes: ['isoCode'], 17 | }), 18 | ], 19 | 20 | attributes: { 21 | name: { 22 | type: DataTypeString, 23 | description: 'The name of the language', 24 | required: true, 25 | }, 26 | 27 | nativeName: { 28 | type: DataTypeString, 29 | description: 'The native name of the language', 30 | required: true, 31 | }, 32 | 33 | isoCode: { 34 | type: DataTypeString, 35 | description: 'ISO code of the language', 36 | required: true, 37 | }, 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /src/engine/i18n.ts: -------------------------------------------------------------------------------- 1 | import { map } from 'lodash'; 2 | import { randomJson } from './util'; 3 | // import { Entity } from '..'; 4 | 5 | export const i18nMockGenerator = ( 6 | entity: any, 7 | _: string, 8 | { dataShaperMap }, 9 | languages = [], 10 | ): object => { 11 | if (entity) { 12 | const content = {}; 13 | 14 | map(entity.getAttributes(), ({ type, i18n, mock }, attributeName) => { 15 | const storageAttributeName = dataShaperMap[attributeName]; 16 | 17 | if (i18n) { 18 | if (Math.random() > 0.5) { 19 | return; 20 | } 21 | 22 | const attributeContent = (content[storageAttributeName] = {}); 23 | 24 | languages.map((language, langIdx) => { 25 | if (langIdx === 0 || Math.random() > 0.5) { 26 | return; 27 | } 28 | 29 | attributeContent[language] = mock ? mock() : type.mock(); 30 | }); 31 | } 32 | }); 33 | 34 | return content; 35 | } 36 | 37 | return randomJson(); 38 | }; 39 | -------------------------------------------------------------------------------- /src/engine/datatype/__snapshots__/DataTypeEnum.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`DataTypeEnum isDataTypeEnum should recognize non-DataTypeEnum objects 1`] = `"Not a DataTypeEnum object"`; 4 | 5 | exports[`DataTypeEnum should have a name 1`] = `"Missing data type name"`; 6 | 7 | exports[`DataTypeEnum should have a set of valid values 1`] = `"Invalid value name '7' for data type 'lorem' (Regex: /^[_a-zA-Z][_a-zA-Z0-9]*$/)"`; 8 | 9 | exports[`DataTypeEnum should have a set of valid values 2`] = `"Invalid value name ' abc ' for data type 'test' (Regex: /^[_a-zA-Z][_a-zA-Z0-9]*$/)"`; 10 | 11 | exports[`DataTypeEnum should have a set of valid values 3`] = `"Invalid value name 'hello-there' for data type 'another' (Regex: /^[_a-zA-Z][_a-zA-Z0-9]*$/)"`; 12 | 13 | exports[`DataTypeEnum should have a set of values 1`] = `"'Missing enum values for data type 'something'"`; 14 | 15 | exports[`DataTypeEnum should have a set of values 2`] = `"'Missing enum values for data type 'something'"`; 16 | -------------------------------------------------------------------------------- /src/engine/datatype/__snapshots__/DataTypeState.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`DataTypeState isDataTypeState should recognize non-DataTypeState objects 1`] = `"Not a DataTypeState object"`; 4 | 5 | exports[`DataTypeState should have a name 1`] = `"Missing data type name"`; 6 | 7 | exports[`DataTypeState should have a set of states 1`] = `"'Missing states for data type 'something'"`; 8 | 9 | exports[`DataTypeState should have a set of states 2`] = `"'Missing states for data type 'something'"`; 10 | 11 | exports[`DataTypeState should have a set of valid states 1`] = `"Invalid state name '6' for data type 'progress' (Regex: /^[a-zA-Z][_a-zA-Z0-9]*$/)"`; 12 | 13 | exports[`DataTypeState should have a set of valid states 2`] = `"Invalid state name ' abc ' for data type 'test' (Regex: /^[a-zA-Z][_a-zA-Z0-9]*$/)"`; 14 | 15 | exports[`DataTypeState should have a set of valid states 3`] = `"Invalid state name 'hello-there' for data type 'another' (Regex: /^[a-zA-Z][_a-zA-Z0-9]*$/)"`; 16 | -------------------------------------------------------------------------------- /storageScripts/get_attribute_translation.tpl.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION <%= functionName %>( 2 | rec ANYELEMENT, 3 | attribute TEXT, 4 | language TEXT 5 | ) RETURNS TEXT AS $$ 6 | DECLARE 7 | languages TEXT[]; 8 | data JSONB; 9 | i18n JSON; 10 | BEGIN 11 | 12 | -- This is an auto-generated function 13 | -- Template (get_attribute_translation.tpl.sql) 14 | 15 | languages := '<%= languages %>'; 16 | 17 | data := to_json(rec); 18 | i18n := (data->'i18n')::JSON; 19 | 20 | IF (language = ANY (languages)) THEN 21 | IF (i18n->attribute IS NOT NULL) THEN 22 | RETURN COALESCE(i18n->attribute->>language, data->>attribute); 23 | END IF; 24 | 25 | IF (data ? attribute) THEN 26 | RETURN data->>attribute; 27 | ELSE 28 | RAISE EXCEPTION 'Unknown attribute name used in <%= functionName %>(): %', attribute; 29 | END IF; 30 | END IF; 31 | 32 | RAISE EXCEPTION 'Unknown language used in <%= functionName %>(): %', language; 33 | END; 34 | $$ 35 | LANGUAGE plpgsql STABLE; 36 | 37 | -------------------------------------------------------------------------------- /storageScripts/get_state_ids.tpl.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE OR REPLACE FUNCTION <%= functionName %>( 3 | entity TEXT, 4 | state_names TEXT[] 5 | ) RETURNS INTEGER[] AS $$ 6 | DECLARE 7 | statesMap JSON; 8 | stateName TEXT; 9 | stateId INTEGER; 10 | result INTEGER[] DEFAULT '{}'; 11 | BEGIN 12 | 13 | -- This is an auto-generated function 14 | -- Template (get_state_ids.tpl.sql) 15 | 16 | statesMap := '<%= statesMap %>'; 17 | 18 | IF (statesMap->entity IS NOT NULL) THEN 19 | FOREACH stateName IN ARRAY state_names LOOP 20 | IF (statesMap->entity->stateName IS NOT NULL) THEN 21 | stateId := statesMap->entity->stateName; 22 | result := result || stateId; 23 | ELSE 24 | RAISE EXCEPTION 'Unknown state name used in <%= functionName %>(): %.%', entity, stateName; 25 | END IF; 26 | END LOOP; 27 | ELSE 28 | RAISE EXCEPTION 'Unknown entity name used in <%= functionName %>(): %', entity; 29 | END IF; 30 | 31 | RETURN result; 32 | END; 33 | $$ 34 | LANGUAGE plpgsql STRICT IMMUTABLE; 35 | -------------------------------------------------------------------------------- /test/data/books.csv: -------------------------------------------------------------------------------- 1 | aut nihil natus iste eos, natus in 2 | ipsum ex placeat quos, omnis dolorem 3 | quae optio repellendus minima reprehenderit, et et 4 | est repellat necessitatibus ipsa nisi, vero numquam 5 | nobis qui beatae minus incidunt, consequatur eos 6 | autem qui inventore sapiente, aliquid ea 7 | sit culpa necessitatibus sit culpa, tempora labore 8 | error qui at nemo magni, dolorem quae 9 | consequatur fugiat a nobis, voluptas velit 10 | molestiae eos omnis omnis enim, vel sed 11 | enim vel et rem labore, amet recusandae 12 | expedita eveniet dicta deleniti aut, distinctio molestiae 13 | consequatur vel sint asperiores, quisquam voluptatem 14 | quam sed deleniti rerum minus, ipsa blanditiis 15 | voluptatem non sapiente recusandae inventore, architecto facilis 16 | debitis et voluptatem consequatur, velit atque 17 | et ut qui eum et, consequatur numquam 18 | aut ab enim nam, modi qui 19 | eum deserunt dolores sed, consequatur aut 20 | deserunt voluptates eum aut libero, nam sit 21 | qui ratione asperiores excepturi dolorem, eos sunt 22 | -------------------------------------------------------------------------------- /src/engine/storage/StorageTypeNull.ts: -------------------------------------------------------------------------------- 1 | import { StorageType } from './StorageType'; 2 | 3 | export const StorageTypeNull = new StorageType({ 4 | name: 'StorageTypeNull', 5 | description: 'Default storage without any implementation', 6 | findOne() { 7 | throw new Error( 8 | "'StorageTypeNull' is not a real storage type implementation", 9 | ); 10 | }, 11 | findOneByValues() { 12 | throw new Error( 13 | "'StorageTypeNull' is not a real storage type implementation", 14 | ); 15 | }, 16 | find() { 17 | throw new Error( 18 | "'StorageTypeNull' is not a real storage type implementation", 19 | ); 20 | }, 21 | count() { 22 | throw new Error( 23 | "'StorageTypeNull' is not a real storage type implementation", 24 | ); 25 | }, 26 | mutate() { 27 | throw new Error( 28 | "'StorageTypeNull' is not a real storage type implementation", 29 | ); 30 | }, 31 | checkLookupPermission() { 32 | throw new Error( 33 | "'StorageTypeNull' is not a real storage type implementation", 34 | ); 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /www/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | text-align: center; 10 | position: relative; 11 | overflow: hidden; 12 | } 13 | 14 | .announcement { 15 | text-align: center; 16 | padding: 10px; 17 | color: #fff; 18 | background: var(--ifm-color-secondary); 19 | } 20 | 21 | .logo { 22 | height: 300px; 23 | } 24 | 25 | .buttons { 26 | /*display: flex;*/ 27 | align-items: center; 28 | justify-content: center; 29 | border: 1px solid #fff; 30 | border-radius: 3px; 31 | color: #fff; 32 | display: inline-block; 33 | font-size: 14px; 34 | font-weight: 400; 35 | line-height: 1.2em; 36 | width: 150px; 37 | padding: 10px; 38 | margin: 3px; 39 | text-decoration: none !important; 40 | text-transform: uppercase; 41 | transition: background 0.3s, color 0.3s; 42 | } 43 | 44 | .buttons:hover { 45 | color: var(--ifm-color-primary); 46 | background: #ffffff; 47 | } 48 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | strict_yaml_branch: master # only use the latest copy on master branch 3 | notify: 4 | require_ci_to_pass: yes 5 | 6 | coverage: 7 | precision: 2 8 | round: down 9 | range: "70...100" 10 | 11 | 12 | # notify: 13 | # slack: 14 | # default: # -> see "sections" below 15 | # url: "https://hooks.slack.com/..." #*S unique Slack notifications url 16 | # branches: null # -> see "branch patterns" below 17 | # threshold: null # -> see "threshold" below 18 | # attachments: "sunburst, diff" # list of attachments to include in notification 19 | # message: "template string" # [advanced] -> see "customized message" below 20 | 21 | 22 | status: 23 | project: yes 24 | patch: yes 25 | changes: no 26 | 27 | parsers: 28 | gcov: 29 | branch_detection: 30 | conditional: yes 31 | loop: yes 32 | method: no 33 | macro: no 34 | 35 | comment: 36 | layout: "header, diff" 37 | behavior: default 38 | require_changes: no 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (https://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules/ 31 | jspm_packages/ 32 | 33 | # TypeScript v1 declaration files 34 | typings/ 35 | 36 | # Optional npm cache directory 37 | .npm 38 | 39 | # Optional eslint cache 40 | .eslintcache 41 | 42 | # Optional REPL history 43 | .node_repl_history 44 | 45 | # Output of 'npm pack' 46 | *.tgz 47 | 48 | # Yarn Integrity file 49 | .yarn-integrity 50 | 51 | # dotenv environment variables file 52 | .env 53 | 54 | playground/* 55 | 56 | # build 57 | lib/* 58 | 59 | # test schema 60 | schema.graphql 61 | 62 | # webstorm 63 | .idea 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2021 Chris Kalmar 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 | -------------------------------------------------------------------------------- /src/graphqlProtocol/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getTypeForEntityFromGraphRegistry, 3 | extendModelsForGql, 4 | registerActions, 5 | generateGraphQLSchema, 6 | } from './generator'; 7 | import { 8 | GraphQLJSON, 9 | GraphQLCursor, 10 | GraphQLBigInt, 11 | GraphQLDateTime, 12 | GraphQLDate, 13 | GraphQLTime, 14 | } from './dataTypes'; 15 | import { ProtocolGraphQL } from './ProtocolGraphQL'; 16 | import { 17 | RELAY_TYPE_PROMOTER_FIELD, 18 | MAX_PAGE_SIZE, 19 | } from './protocolGraphqlConstants'; 20 | import { 21 | ProtocolGraphQLConfiguration, 22 | isProtocolGraphQLConfiguration, 23 | } from './ProtocolGraphQLConfiguration'; 24 | import { fromBase64, toBase64 } from './util'; 25 | 26 | export { 27 | getTypeForEntityFromGraphRegistry, 28 | extendModelsForGql, 29 | registerActions, 30 | generateGraphQLSchema, 31 | GraphQLJSON, 32 | GraphQLCursor, 33 | GraphQLBigInt, 34 | GraphQLDateTime, 35 | GraphQLDate, 36 | GraphQLTime, 37 | ProtocolGraphQL, 38 | RELAY_TYPE_PROMOTER_FIELD, 39 | MAX_PAGE_SIZE, 40 | ProtocolGraphQLConfiguration, 41 | isProtocolGraphQLConfiguration, 42 | fromBase64, 43 | toBase64, 44 | }; 45 | -------------------------------------------------------------------------------- /storageScripts/merge_translations.tpl.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE OR REPLACE FUNCTION <%= functionName %>( 3 | original JSONB, 4 | diff JSONB 5 | ) RETURNS JSONB AS $$ 6 | DECLARE 7 | result JSONB := original; 8 | attributeName TEXT; 9 | translationsOriginal JSONB; 10 | translationsDiff JSONB; 11 | BEGIN 12 | IF (original IS NULL) THEN 13 | RETURN diff; 14 | ELSEIF (diff IS NULL) THEN 15 | RETURN original; 16 | ELSE 17 | FOR attributeName IN SELECT * FROM jsonb_object_keys(diff) 18 | LOOP 19 | translationsOriginal := original #> ARRAY[attributeName]; 20 | translationsDiff := diff #> ARRAY[attributeName]; 21 | 22 | IF (translationsOriginal IS NULL) THEN 23 | result := jsonb_set(result, ARRAY[attributeName], translationsDiff); 24 | ELSEIF (translationsDiff IS NULL) THEN 25 | result := jsonb_set(result, ARRAY[attributeName], translationsOriginal); 26 | ELSE 27 | result := jsonb_set(result, ARRAY[attributeName], translationsOriginal || translationsDiff); 28 | END IF; 29 | 30 | END LOOP; 31 | 32 | RETURN result; 33 | END IF; 34 | END; 35 | $$ 36 | LANGUAGE plpgsql IMMUTABLE; 37 | -------------------------------------------------------------------------------- /src/engine/schema/__snapshots__/Schema.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Schema should reject an invalid entities list 1`] = `"Schema needs 'entities' to be an array of type Entity"`; 4 | 5 | exports[`Schema should reject an invalid entities list 2`] = `"Schema needs 'entities' to be an array of type Entity"`; 6 | 7 | exports[`Schema should reject an invalid entities list 3`] = `"Schema needs 'entities' to be an array of type Entity"`; 8 | 9 | exports[`Schema should reject an invalid entities list 4`] = `"Provided object to schema is not an entity or view entity or shadow entity"`; 10 | 11 | exports[`Schema should reject invalid entities 1`] = `"Provided object to schema is not an entity or view entity or shadow entity"`; 12 | 13 | exports[`Schema should reject invalid entities 2`] = `"Provided object to schema is not an entity or view entity or shadow entity"`; 14 | 15 | exports[`Schema should reject invalid entities 3`] = `"Provided object to schema is not an entity or view entity or shadow entity"`; 16 | 17 | exports[`Schema should throw on duplicate entities 1`] = `"Entity 'FirstEntity' already registered with this schema"`; 18 | -------------------------------------------------------------------------------- /src/engine/datatype/DataTypeUser.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | 3 | import { DataTypeUser, isDataTypeUser } from './DataTypeUser'; 4 | 5 | import { passOrThrow } from '../util'; 6 | 7 | describe('DataTypeUser', () => { 8 | describe('isDataTypeUser', () => { 9 | it('should recognize objects of type DataTypeUser', () => { 10 | const dataTypeUser = new DataTypeUser({ 11 | name: 'user', 12 | description: 'some description', 13 | mock() {}, 14 | }); 15 | 16 | function fn() { 17 | passOrThrow( 18 | isDataTypeUser(dataTypeUser), 19 | () => 'This error will never happen', 20 | ); 21 | } 22 | 23 | expect(fn).not.toThrow(); 24 | }); 25 | 26 | it('should recognize non-DataTypeUser objects', () => { 27 | function fn() { 28 | passOrThrow( 29 | isDataTypeUser({}) || 30 | isDataTypeUser(function test() {}) || 31 | isDataTypeUser(Error), 32 | () => 'Not a DataType object', 33 | ); 34 | } 35 | 36 | expect(fn).toThrowErrorMatchingSnapshot(); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/testingData.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | const buildArray = (length: number, value = 1) => { 4 | return new Array(length).fill(value); 5 | }; 6 | 7 | export const readRows = (fileName: string): string[][] => { 8 | const filePath = `${__dirname}/data/${fileName}.csv`; 9 | const content = fs.readFileSync(filePath, 'utf8'); 10 | return content 11 | .split('\n') 12 | .filter((row) => row.length) 13 | .map((row) => { 14 | // take care of escaped commas '\,' 15 | const prep = row.replace(/\\,/g, '|||'); 16 | const columns = prep.split(','); 17 | return columns.map((column) => column.replace(/\|\|\|/g, ',')); 18 | }); 19 | }; 20 | 21 | export const writeTestDataFile = ( 22 | fileName: string, 23 | content: string[], 24 | ): void => { 25 | const filePath = `${__dirname}/data/${fileName}.csv`; 26 | fs.writeFileSync(filePath, `${content.join('\n')}\n`, 'utf8'); 27 | }; 28 | 29 | export const generateRows = ( 30 | count: number, 31 | fileName: string, 32 | generator: (arg: number) => any[], 33 | ): void => { 34 | const content = buildArray(count).map((_item, idx) => { 35 | return generator(idx).join(','); 36 | }); 37 | 38 | writeTestDataFile(fileName, content); 39 | }; 40 | -------------------------------------------------------------------------------- /src/engine/storage/__snapshots__/StorageDataType.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`StorageDataType isStorageDataType should recognize non-StorageDataType objects 1`] = `"Not a StorageDataType object"`; 4 | 5 | exports[`StorageDataType should have a description 1`] = `"Missing description for storage data type 'example'"`; 6 | 7 | exports[`StorageDataType should have a name 1`] = `"Missing storage data type name"`; 8 | 9 | exports[`StorageDataType should have a native data type 1`] = `"Missing native data type for storage data type 'someStorageDataType'"`; 10 | 11 | exports[`StorageDataType should have a serialize function 1`] = `"Storage data type 'someStorageDataType' has an invalid serialize function"`; 12 | 13 | exports[`StorageDataType should have a valid list of capabilities if provided 1`] = `"Storage data type 'someStorageDataType' has an invalid list of capabilities"`; 14 | 15 | exports[`StorageDataType should have a valid parse function if provided 1`] = `"Storage data type 'someStorageDataType' has an invalid parse function"`; 16 | 17 | exports[`StorageDataType should reject invalid capabilities 1`] = `"Storage data type 'someStorageDataType' has an unknown capability 'magic_unicorn_filter'"`; 18 | -------------------------------------------------------------------------------- /src/engine/datatype/__snapshots__/ListDataType.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ListDataType isListDataType should recognize non-ListDataType objects 1`] = `"Not a ListDataType object"`; 4 | 5 | exports[`ListDataType should accept only valid item lenght ranges 1`] = `"List data type 'Example' has invalid minItems setting '-1'"`; 6 | 7 | exports[`ListDataType should accept only valid item lenght ranges 2`] = `"List data type 'Example' has invalid maxItems setting '-1'"`; 8 | 9 | exports[`ListDataType should accept only valid item lenght ranges 3`] = `"List data type 'Example' has a bigger minItems than the maxItems setting"`; 10 | 11 | exports[`ListDataType should accept only valid item types 1`] = `"List data type 'Example' has invalid item type '2,7,13'"`; 12 | 13 | exports[`ListDataType should have a description 1`] = `"Missing description for list data type 'example'"`; 14 | 15 | exports[`ListDataType should have a name 1`] = `"Missing list data type name"`; 16 | 17 | exports[`ListDataType should have an item type 1`] = `"Missing item type for list data type 'Example'"`; 18 | 19 | exports[`ListDataType should reject invalid dynamic item types 1`] = `"List data type 'Example' has invalid dynamic item type 'test'"`; 20 | -------------------------------------------------------------------------------- /storageScripts/get_attribute_translations.tpl.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION <%= functionName %>( 2 | rec ANYELEMENT, 3 | attribute TEXT 4 | ) RETURNS TEXT AS $$ 5 | DECLARE 6 | languages TEXT[]; 7 | language TEXT; 8 | defaultLanguage TEXT; 9 | data JSONB; 10 | i18n JSON; 11 | result JSONB DEFAULT '{}'; 12 | BEGIN 13 | 14 | -- This is an auto-generated function 15 | -- Template (get_attribute_translations.tpl.sql) 16 | 17 | languages := '<%= languages %>'; 18 | defaultLanguage := '<%= defaultLanguage %>'; 19 | 20 | data := to_json(rec); 21 | i18n := (data->'i18n')::JSON; 22 | 23 | IF (data ? attribute) THEN 24 | 25 | result := result || jsonb_build_object(defaultLanguage, data->>attribute); 26 | 27 | IF (i18n->attribute IS NOT NULL) THEN 28 | FOREACH language IN ARRAY languages LOOP 29 | IF (language <> defaultLanguage AND i18n->attribute IS NOT NULL) THEN 30 | result := result || jsonb_build_object(language, i18n->attribute->>language); 31 | END IF; 32 | END LOOP; 33 | END IF; 34 | 35 | RETURN result; 36 | ELSE 37 | RAISE EXCEPTION 'Unknown attribute name used in <%= functionName %>(): %', attribute; 38 | END IF; 39 | END; 40 | $$ 41 | LANGUAGE plpgsql STABLE; 42 | 43 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | - '10' 5 | 6 | services: 7 | - postgresql 8 | 9 | addons: 10 | postgresql: '9.6' 11 | 12 | before_script: 13 | - psql -c 'CREATE DATABASE shyft_tests;' -U postgres 14 | 15 | script: 16 | - ( npm run lint || true ) && 17 | npm run coverage-ci 18 | 19 | before_deploy: 20 | - npm run clean && 21 | ( npm run build || true ) 22 | 23 | deploy: 24 | skip_cleanup: true 25 | provider: npm 26 | email: christian.kalmar@gmail.com 27 | api_key: 28 | secure: dnvZL52w4zLE7b2v+t9yhljKEdbJtDJwveACU5Viq8NaAHtX2xMJLBywDHUl55gfY3nlg2pjK6Lj8wcx9zXfDxtWsQtU6sxp+hjmhqQ+xunBKEakOXBRZPDzep8GXYHVCf/2/4S0XSyvle/vmDWD5s/ygt1EVivQUIonLdNMoS3YKkDJtYJun+i0o0+KeHoX/J/M3A7G1eStLWNuk3gmeBOQzFD6HD/V5iehAox1ArexBudnE7igInjeNst2NQXbzfF1FgEcIBqb8mPW2K7WIGYWk/l2GDazuVIjz90E8Sp5kKTahs9l39QzViDjHJwWA0/mQUiqkibwDrEJBpONLuKgrCahISJ3IOHLg5Rj8x9kpnAK2fddYYftF5HKxNtqpYzzNvLHbbdua32uO+b0qZxN2OrhNV240sBAgvJ71LA/IeSw8XvFV+ltzGtyuzzGK2FCF0lICfNwn3H78eVkhiALRf8VOBJhNz4EVwbJFfCVfjFKq90eNGUbTX9ckelAbugVO9F/8+2sEq/HXK4wlyLrH9oBC4inhwzH369Y+XVYezXg8Y3SvKlvmokX9oLVjGzCnFjIuCAjpClupHGRxU02OPv88Gudpmc5YSIybcuBk6KKMK8yWm9s44VdklT0yLBEs6Z3gNvWNxaoVHmU4f6Z2szEVZTzKwv+1coSlp0= 29 | on: 30 | tags: true 31 | repo: chriskalmar/shyft 32 | node: '12' 33 | -------------------------------------------------------------------------------- /test/storageDataTypes.spec.ts: -------------------------------------------------------------------------------- 1 | import './setupAndTearDown'; 2 | import { mutate, findOne } from './db'; 3 | import { asAdmin } from './testUtils'; 4 | import { DataTypeTester } from './models/DataTypeTester'; 5 | 6 | describe('storageDataTypes', () => { 7 | it('persist and read data of various types', async () => { 8 | const payload = { 9 | typeId: 43455523, 10 | typeInteger: 729393827, 11 | typeBigInt: '34567890987654', 12 | typeFloat: 934234.111243, 13 | typeBoolean: true, 14 | typeString: 'Random text', 15 | typeJson: { simple: 123, nested: { level2: [1, 6, 9] } }, 16 | typeTimestamp: '2000-01-02 11:12:13', 17 | typeTimestampTz: '2001-02-03 12:13:14+15:16', 18 | typeDate: '2002-03-04', 19 | typeTime: '11:12:13', 20 | typeTimeTz: '12:13:14+15:16', 21 | typeUuid: '7f7fa058-7072-44a8-a70b-a5846f63b3fd', 22 | }; 23 | 24 | const persist = await mutate( 25 | DataTypeTester, 26 | 'create', 27 | payload, 28 | null, 29 | asAdmin(), 30 | ); 31 | 32 | expect(persist).toMatchSnapshot(); 33 | 34 | // eslint-disable-next-line dot-notation 35 | const result = await findOne(DataTypeTester, persist['id'], {}, asAdmin()); 36 | expect(result).toMatchSnapshot(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shyft-website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "2.0.0-beta.0", 18 | "@docusaurus/preset-classic": "2.0.0-beta.0", 19 | "@mdx-js/react": "1.6.22", 20 | "@svgr/webpack": "5.5.0", 21 | "clsx": "1.1.1", 22 | "file-loader": "6.2.0", 23 | "react": "17.0.2", 24 | "react-dom": "17.0.2", 25 | "url-loader": "4.1.1" 26 | }, 27 | "browserslist": { 28 | "production": [ 29 | ">0.5%", 30 | "not dead", 31 | "not op_mini all" 32 | ], 33 | "development": [ 34 | "last 1 chrome version", 35 | "last 1 firefox version", 36 | "last 1 safari version" 37 | ] 38 | }, 39 | "devDependencies": { 40 | "docusaurus-plugin-typedoc": "0.14.2", 41 | "typedoc": "0.20.36", 42 | "typedoc-plugin-markdown": "3.9.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/storage-connector/index.ts: -------------------------------------------------------------------------------- 1 | import StorageTypePostgres from './StorageTypePostgres'; 2 | import StoragePostgresConfiguration, { 3 | isStoragePostgresConfiguration, 4 | } from './StoragePostgresConfiguration'; 5 | 6 | import { 7 | loadModels, 8 | generateMockData, 9 | connectStorage, 10 | disconnectStorage, 11 | } from './generator'; 12 | 13 | import { 14 | generateMigration, 15 | runMigration, 16 | revertMigration, 17 | fillMigrationsTable, 18 | migrateI18nIndices, 19 | } from './migration'; 20 | 21 | import { runTestPlaceholderQuery } from './helpers'; 22 | 23 | export { 24 | StorageTypePostgres, 25 | loadModels, 26 | generateMockData, 27 | connectStorage, 28 | disconnectStorage, 29 | StoragePostgresConfiguration, 30 | isStoragePostgresConfiguration, 31 | generateMigration, 32 | runMigration, 33 | revertMigration, 34 | fillMigrationsTable, 35 | migrateI18nIndices, 36 | runTestPlaceholderQuery, 37 | }; 38 | 39 | export default { 40 | StorageTypePostgres, 41 | loadModels, 42 | generateMockData, 43 | connectStorage, 44 | disconnectStorage, 45 | StoragePostgresConfiguration, 46 | isStoragePostgresConfiguration, 47 | generateMigration, 48 | runMigration, 49 | revertMigration, 50 | fillMigrationsTable, 51 | migrateI18nIndices, 52 | runTestPlaceholderQuery, 53 | }; 54 | -------------------------------------------------------------------------------- /src/graphqlProtocol/__snapshots__/dataTypes.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`dataTypes GraphQLDate 1`] = `"Query error: String is not a valid date string"`; 4 | 5 | exports[`dataTypes GraphQLDate: bad date 1`] = `"Query error: String is not a valid date string"`; 6 | 7 | exports[`dataTypes GraphQLDate: bad day 1`] = `"Query error: String is not a valid date string"`; 8 | 9 | exports[`dataTypes GraphQLDate: bad month 1`] = `"Query error: String is not a valid date string"`; 10 | 11 | exports[`dataTypes GraphQLDateTime 1`] = `"Query error: String is not a valid date time string"`; 12 | 13 | exports[`dataTypes GraphQLDateTime: bad hour 1`] = `"Query error: String is not a valid date time string"`; 14 | 15 | exports[`dataTypes GraphQLDateTime: bad minute 1`] = `"Query error: String is not a valid date time string"`; 16 | 17 | exports[`dataTypes GraphQLDateTime: bad month 1`] = `"Query error: String is not a valid date time string"`; 18 | 19 | exports[`dataTypes GraphQLTime 1`] = `"Query error: String is not a valid time string"`; 20 | 21 | exports[`dataTypes GraphQLTime: bad hour 1`] = `"Query error: String is not a valid time string"`; 22 | 23 | exports[`dataTypes GraphQLTime: bad minute 1`] = `"Query error: String is not a valid time string"`; 24 | 25 | exports[`dataTypes GraphQLTime: bad tz hour 1`] = `"Query error: String is not a valid time string"`; 26 | -------------------------------------------------------------------------------- /src/engine/models/User.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '../entity/Entity'; 2 | import { DataTypeString } from '../datatype/dataTypes'; 3 | import { Index, INDEX_UNIQUE } from '../index/Index'; 4 | 5 | import { Language } from './Language'; 6 | 7 | export const User = new Entity({ 8 | name: 'User', 9 | description: 'A user', 10 | 11 | // isUserTable: true, 12 | // sequenceGenerator: 'timebased', 13 | 14 | indexes: [ 15 | new Index({ 16 | type: INDEX_UNIQUE, 17 | attributes: ['username'], 18 | }), 19 | new Index({ 20 | type: INDEX_UNIQUE, 21 | attributes: ['email'], 22 | }), 23 | ], 24 | 25 | attributes: { 26 | username: { 27 | type: DataTypeString, 28 | description: 'Username', 29 | required: true, 30 | }, 31 | 32 | firstName: { 33 | type: DataTypeString, 34 | description: 'First name', 35 | }, 36 | 37 | lastName: { 38 | type: DataTypeString, 39 | description: 'Last name', 40 | }, 41 | 42 | language: { 43 | type: Language, 44 | description: 'User language', 45 | required: true, 46 | }, 47 | 48 | email: { 49 | type: DataTypeString, 50 | description: 'Email address', 51 | required: true, 52 | }, 53 | 54 | password: { 55 | type: DataTypeString, 56 | description: 'User password', 57 | required: true, 58 | }, 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /test/integration.spec.ts: -------------------------------------------------------------------------------- 1 | import './setupAndTearDown'; 2 | import { count } from './db'; 3 | 4 | import { asAdmin } from './testUtils'; 5 | 6 | import { counts } from './testSetGenerator'; 7 | 8 | import { Profile } from './models/Profile'; 9 | import { Board } from './models/Board'; 10 | import { BoardMember } from './models/BoardMember'; 11 | import { StorageTypePostgres } from '../src/storage-connector/StorageTypePostgres'; 12 | 13 | describe('postgres', () => { 14 | it('test data imported correctly', async () => { 15 | const profileCount = await count(Profile, {}, asAdmin()); 16 | expect(profileCount).toEqual(counts.profileCount); 17 | 18 | const boardCount = await count(Board, {}, asAdmin()); 19 | expect(boardCount).toEqual(counts.boardCount); 20 | 21 | const memberCount = await count(BoardMember, {}, asAdmin()); 22 | expect(memberCount).toEqual(counts.joinCount + counts.inviteCount); 23 | }); 24 | 25 | it('should generated indices', async () => { 26 | const storageInstance = StorageTypePostgres.getStorageInstance(); 27 | const manager = storageInstance.manager; 28 | const indexes = await manager.query(` 29 | select 30 | indexname, 31 | indexdef ILIKE '%UNIQUE%' AS unique 32 | from pg_indexes 33 | where tablename = 'board' 34 | order by indexname 35 | `); 36 | 37 | expect(indexes).toMatchSnapshot('indexList'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/engine/entity/__snapshots__/ShadowEntity.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ShadowEntity attributes should catch invalid attribute names 1`] = `"Invalid attribute name 'wrong-named-attribute' in shadow entity 'ShadowViewEntityName' (Regex: /^[a-zA-Z][a-zA-Z0-9_]*$/)"`; 4 | 5 | exports[`ShadowEntity attributes should reject attributes with missing or invalid data type 1`] = `"'ShadowViewEntityName.someAttribute' has invalid data type 'undefined'"`; 6 | 7 | exports[`ShadowEntity isShadowEntity should recognize non-ShadowEntity objects 1`] = `"Not a ShadowEntity object"`; 8 | 9 | exports[`ShadowEntity references should throw if invalid attribute is to be referenced 1`] = `"Cannot reference attribute 'Country.notHere' as it does not exist"`; 10 | 11 | exports[`ShadowEntity should accept only maps or functions as attributes definition 1`] = `"'attributes' for shadow entity 'Example' needs to be a map of attributes or a function returning a map of attributes"`; 12 | 13 | exports[`ShadowEntity should have a name 1`] = `"Missing shadow entity name"`; 14 | 15 | exports[`ShadowEntity should reject non-map results of attribute definition functions 1`] = `"Attribute definition function for shadow entity 'Example' does not return a map"`; 16 | 17 | exports[`ShadowEntity should throw if invalid storage type was provided 1`] = `"ShadowEntity 'Example' needs a valid storage type (defaults to 'StorageTypeNull')"`; 18 | -------------------------------------------------------------------------------- /src/engine/__snapshots__/util.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`util mapOverProperties should throw if iteratee is not a function 1`] = `"Provided iteratee is not a function"`; 4 | 5 | exports[`util mapOverProperties should throw if iteratee is not a function 2`] = `"Provided iteratee is not a function"`; 6 | 7 | exports[`util mapOverProperties should throw if iteratee is not a function 3`] = `"Provided iteratee is not a function"`; 8 | 9 | exports[`util mapOverProperties should throw if non-maps are provided 1`] = `"Provided object is not a map"`; 10 | 11 | exports[`util mapOverProperties should throw if non-maps are provided 2`] = `"Provided object is not a map"`; 12 | 13 | exports[`util mapOverProperties should throw if non-maps are provided 3`] = `"Provided object is not a map"`; 14 | 15 | exports[`util mergeMaps should throw if non-maps are provided 1`] = `"mergeMaps() expects 2 maps for a merge to work"`; 16 | 17 | exports[`util mergeMaps should throw if non-maps are provided 2`] = `"mergeMaps() expects 2 maps for a merge to work"`; 18 | 19 | exports[`util mergeMaps should throw if non-maps are provided 3`] = `"mergeMaps() expects 2 maps for a merge to work"`; 20 | 21 | exports[`util passOrThrow should throw if message is not a function 1`] = `"passOrThrow() expects messageFn to be a function"`; 22 | 23 | exports[`util passOrThrow should throw on negative condition 1`] = `"This is the very very super important error"`; 24 | -------------------------------------------------------------------------------- /src/engine/__snapshots__/cursor.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`cursor processCursor should throw if an attribute is used which the data set is not sorted by 1`] = `"orderBy needs to be an array of order definitions"`; 4 | 5 | exports[`cursor processCursor should throw if an attribute is used which the data set is not sorted by 2`] = `"Cursor works only on sorted attributes (check: 'loginName')"`; 6 | 7 | exports[`cursor processCursor should throw if an attribute is used which the data set is not sorted by 3`] = `"Cursor works only on sorted attributes (check: 'email')"`; 8 | 9 | exports[`cursor processCursor should throw if cursor is malformed 1`] = `"Cursor malformed"`; 10 | 11 | exports[`cursor processCursor should throw if cursor is malformed 2`] = `"Cursor malformed"`; 12 | 13 | exports[`cursor processCursor should throw if cursor is malformed 3`] = `"Cursor malformed"`; 14 | 15 | exports[`cursor processCursor should throw if incompatible cursor provided 1`] = `"Incompatible cursor for this entity"`; 16 | 17 | exports[`cursor processCursor should throw if none of the attributes are defined as unique 1`] = `"Cursor needs to have at least one attribute defined as unique"`; 18 | 19 | exports[`cursor processCursor should throw if none of the attributes are defined as unique 2`] = `"Cursor needs to have at least one attribute defined as unique"`; 20 | 21 | exports[`cursor processCursor should throw if unknown attribute is used 1`] = `"Unknown attribute 'iDontKnow' used in cursor"`; 22 | -------------------------------------------------------------------------------- /src/engine/protocol/__snapshots__/ProtocolType.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ProtocolType should implement isProtocolDataType 1`] = `"Protocol type 'REST' needs to implement isProtocolDataType()"`; 4 | 5 | exports[`ProtocolType should reject duplicate data type mappings 1`] = `"Data type mapping for 'DataTypeInteger' already registered with protocol type 'ProtocolTypeREST'"`; 6 | 7 | exports[`ProtocolType should reject invalid data type mappings 1`] = `"Provided schemaDataType is not a valid data type in 'ProtocolTypeREST'"`; 8 | 9 | exports[`ProtocolType should reject invalid data type mappings 2`] = `"Provided protocolDataType for 'DataTypeID' is not a valid protocol data type in 'ProtocolTypeREST'"`; 10 | 11 | exports[`ProtocolType should reject invalid data type mappings 3`] = `"Provided protocolDataType for 'DataTypeString' is not a valid protocol data type in 'ProtocolTypeREST'"`; 12 | 13 | exports[`ProtocolType should reject invalid protocol type definitions 1`] = `"Missing protocol type name"`; 14 | 15 | exports[`ProtocolType should reject invalid protocol type definitions 2`] = `"Missing description for protocol type 'REST'"`; 16 | 17 | exports[`ProtocolType should throw if unknown data type mapping is requested 1`] = `"Provided schemaDataType is not a valid data type in protocol type 'ProtocolTypeREST'"`; 18 | 19 | exports[`ProtocolType should throw if unknown data type mapping is requested 2`] = `"No data type mapping found for 'DataTypeBoolean' in protocol type 'ProtocolTypeREST'"`; 20 | -------------------------------------------------------------------------------- /src/engine/protocol/ProtocolConfiguration.ts: -------------------------------------------------------------------------------- 1 | import { passOrThrow, isString, isArray } from '../util'; 2 | import { Configuration } from '../configuration/Configuration'; 3 | 4 | export type ProtocolConfigurationSetup = { 5 | features?: string[]; 6 | }; 7 | 8 | export class ProtocolConfiguration { 9 | features: { [key: string]: boolean }; 10 | getParentConfiguration: () => Configuration; 11 | 12 | constructor( 13 | setup: ProtocolConfigurationSetup = {} as ProtocolConfigurationSetup, 14 | ) { 15 | // this.features = []; 16 | this.features = {}; 17 | 18 | const { features } = setup; 19 | 20 | if (features) { 21 | this.enableFeatures(features); 22 | } 23 | } 24 | 25 | enableFeature(feature: string, enable = true): void { 26 | passOrThrow( 27 | isString(feature), 28 | () => 'enableFeature() expects a feature name', 29 | ); 30 | 31 | this.features[feature] = !!enable; 32 | } 33 | 34 | enableFeatures(features: string[], enable = true): void { 35 | passOrThrow( 36 | isArray(features), 37 | () => 'enableFeatures() expects an array of feature names', 38 | ); 39 | 40 | features.map((feature) => this.enableFeature(feature, enable)); 41 | } 42 | 43 | getEnabledFeatures(): { [key: string]: boolean } { 44 | return this.features; 45 | } 46 | } 47 | 48 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 49 | export const isProtocolConfiguration = ( 50 | obj: unknown, 51 | ): obj is ProtocolConfiguration => { 52 | return obj instanceof ProtocolConfiguration; 53 | }; 54 | -------------------------------------------------------------------------------- /src/engine/__snapshots__/validation.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`validation should reject payloads based on attribute level validation 1`] = `"Too short"`; 4 | 5 | exports[`validation should reject payloads based on attribute level validation 2`] = `"Too long"`; 6 | 7 | exports[`validation should reject payloads based on attribute level validation 3`] = `"Missing context"`; 8 | 9 | exports[`validation should reject payloads based on attribute level validation 4`] = `"Value needs to be between 0.0 and 1.0 (got: 4.7)"`; 10 | 11 | exports[`validation should reject payloads based on attribute level validation of nested attributes 1`] = `"Missing \\"Team\\" in team name"`; 12 | 13 | exports[`validation should reject payloads based on attribute level validation of nested attributes 2`] = `"Players need to have even numbers (got 5)"`; 14 | 15 | exports[`validation should reject payloads based on attribute level validation of nested attributes 3`] = `"Players need to have even numbers (got 9)"`; 16 | 17 | exports[`validation should reject payloads based on attribute level validation of nested attributes 4`] = `"Firstname too short"`; 18 | 19 | exports[`validation should reject payloads based on data type level validation 1`] = `"Object data type 'offense' expects an object"`; 20 | 21 | exports[`validation should reject payloads based on data type level validation 2`] = `"List data type 'offense' expects an array of items"`; 22 | 23 | exports[`validation should reject payloads with missing required attributes 1`] = `"Missing required input attribute 'someAttribute'"`; 24 | -------------------------------------------------------------------------------- /src/engine/index/__snapshots__/Index.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Index isIndex should recognize non-Index objects 1`] = `"Not an Index object"`; 4 | 5 | exports[`Index processEntityIndexes should throw if attribute used in index does not exist 1`] = `"Cannot use attribute 'SomeEntityName.notHere' in index as it does not exist"`; 6 | 7 | exports[`Index processEntityIndexes should throw if provided with an invalid index 1`] = `"Invalid index definition for entity 'SomeEntityName' at position '0'"`; 8 | 9 | exports[`Index processEntityIndexes should throw if provided with an invalid list of indexes 1`] = `"Entity 'SomeEntityName' indexes definition needs to be an array of indexes"`; 10 | 11 | exports[`Index should accept attribute names only 1`] = `"Index definition of type 'unique' needs to have a list of attribute names"`; 12 | 13 | exports[`Index should accept attribute names only 2`] = `"Index definition of type 'unique' needs to have a list of attribute names"`; 14 | 15 | exports[`Index should accept unique attribute names only 1`] = `"Index definition of type 'unique' needs to have a list of unique attribute names"`; 16 | 17 | exports[`Index should have a list of attributes 1`] = `"Index definition of type 'unique' needs to have a list of attributes"`; 18 | 19 | exports[`Index should have a list of attributes 2`] = `"Index definition of type 'unique' needs to have a list of attributes"`; 20 | 21 | exports[`Index should have a type 1`] = `"Missing index type"`; 22 | 23 | exports[`Index should have a valid type 1`] = `"Unknown index type 'something' used, try one of these: 'unique, generic'"`; 24 | -------------------------------------------------------------------------------- /src/engine/datatype/__snapshots__/ObjectDataType.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ObjectDataType isObjectDataType should recognize non-DataType objects 1`] = `"Not a DataType object"`; 4 | 5 | exports[`ObjectDataType should accept only maps or functions as attributes definition 1`] = `"Object data type 'Example' needs an attribute definition as a map or a function returning such a map"`; 6 | 7 | exports[`ObjectDataType should have a description 1`] = `"Missing description for object data type 'example'"`; 8 | 9 | exports[`ObjectDataType should have a map of attributes 1`] = `"Missing attributes for object data type 'Example'"`; 10 | 11 | exports[`ObjectDataType should have a name 1`] = `"Missing object data type name"`; 12 | 13 | exports[`ObjectDataType should reject attributes with invalid data types 1`] = `"'Example.name' has invalid data type '[object Object]'"`; 14 | 15 | exports[`ObjectDataType should reject attributes with invalid defaultValue functions 1`] = `"'Example.name' has an invalid defaultValue function'"`; 16 | 17 | exports[`ObjectDataType should reject attributes with invalid resolve functions 1`] = `"'Example.name' has an invalid resolve function'"`; 18 | 19 | exports[`ObjectDataType should reject attributes with missing descriptions 1`] = `"Missing description for 'Example.name'"`; 20 | 21 | exports[`ObjectDataType should reject empty attribute maps 1`] = `"Object data type 'Example' has no attributes defined"`; 22 | 23 | exports[`ObjectDataType should reject non-map results of attribute definition functions 1`] = `"Attribute definition function for object data type 'Example' does not return a map"`; 24 | -------------------------------------------------------------------------------- /test/__snapshots__/storageDataTypes.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`storageDataTypes persist and read data of various types 1`] = ` 4 | Object { 5 | "_type_": "dataTypeTester", 6 | "id": "1", 7 | "typeBigInt": "34567890987654", 8 | "typeBoolean": true, 9 | "typeDate": 2002-03-04T00:00:00.000Z, 10 | "typeFloat": "934234.111243", 11 | "typeId": "43455523", 12 | "typeInteger": 729393827, 13 | "typeJson": Object { 14 | "nested": Object { 15 | "level2": Array [ 16 | 1, 17 | 6, 18 | 9, 19 | ], 20 | }, 21 | "simple": 123, 22 | }, 23 | "typeString": "Random text", 24 | "typeTime": "11:12:13", 25 | "typeTimeTz": "12:13:14+15:16", 26 | "typeTimestamp": 2000-01-02T11:12:13.000Z, 27 | "typeTimestampTz": 2001-02-02T20:57:14.000Z, 28 | "typeUuid": "7f7fa058-7072-44a8-a70b-a5846f63b3fd", 29 | } 30 | `; 31 | 32 | exports[`storageDataTypes persist and read data of various types 2`] = ` 33 | DataTypeTester { 34 | "id": "1", 35 | "typeBigInt": "34567890987654", 36 | "typeBoolean": true, 37 | "typeDate": "2002-03-04", 38 | "typeFloat": "934234.111243", 39 | "typeId": "43455523", 40 | "typeInteger": 729393827, 41 | "typeJson": Object { 42 | "nested": Object { 43 | "level2": Array [ 44 | 1, 45 | 6, 46 | 9, 47 | ], 48 | }, 49 | "simple": 123, 50 | }, 51 | "typeString": "Random text", 52 | "typeTime": "11:12:13", 53 | "typeTimeTz": "12:13:14+15:16", 54 | "typeTimestamp": 2000-01-02T11:12:13.000Z, 55 | "typeTimestampTz": 2001-02-02T20:57:14.000Z, 56 | "typeUuid": "7f7fa058-7072-44a8-a70b-a5846f63b3fd", 57 | } 58 | `; 59 | -------------------------------------------------------------------------------- /src/engine/__snapshots__/filter.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`filter validateFilterLevel should throw if invalid attributes are used in filter 1`] = `"Unknown attribute name 'something' used in filter"`; 4 | 5 | exports[`filter validateFilterLevel should throw if invalid attributes are used in filter 2`] = `"Unknown attribute name 'anything_here' used in filter"`; 6 | 7 | exports[`filter validateFilterLevel should throw if invalid attributes are used in filter 3`] = `"Unknown attribute name 'anything_here' used in filter at 'just.here'"`; 8 | 9 | exports[`filter validateFilterLevel should throw if invalid operators are used in filter 1`] = `"Unknown or incompatible operator '$ends_with' used on 'isActive' in filter"`; 10 | 11 | exports[`filter validateFilterLevel should throw if invalid operators are used in filter 2`] = `"Unknown or incompatible operator 'anything' used on 'firstName' in filter"`; 12 | 13 | exports[`filter validateFilterLevel should throw if provided params are invalid 1`] = `"filter needs to be an object of filter criteria"`; 14 | 15 | exports[`filter validateFilterLevel should throw if provided params are invalid 2`] = `"optional path in validateFilterLevel() needs to be an array"`; 16 | 17 | exports[`filter validateFilterLevel should throw if provided params are invalid 3`] = `"filter at 'somewhere' needs to be an object of filter criteria"`; 18 | 19 | exports[`filter validateFilterLevel should throw if provided params are invalid 4`] = `"filter at 'somewhere.deeply.nested' needs to be an object of filter criteria"`; 20 | 21 | exports[`filter validateFilterLevel should throw if provided params are invalid 5`] = `"validateFilterLevel() expects an attribute map"`; 22 | -------------------------------------------------------------------------------- /test/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '../src/engine/context/Context'; 2 | import { formatGraphQLError } from '../src/graphqlProtocol/util'; 3 | 4 | export const asUser = ( 5 | userId: number | string, 6 | userRoles: string[] = [], 7 | i18nLanguage = 'en', 8 | ): Context => { 9 | return { 10 | loaders: {}, 11 | userId, 12 | userRoles, 13 | i18nLanguage, 14 | }; 15 | }; 16 | 17 | export const asAdmin = (userId = 1, i18nLanguage?: string): Context => { 18 | return asUser(userId, ['admin'], i18nLanguage); 19 | }; 20 | 21 | export const asAnonymous = (i18nLanguage?: string): Context => { 22 | return { 23 | loaders: {}, 24 | i18nLanguage, 25 | }; 26 | }; 27 | 28 | export const sleep = async (ms) => 29 | new Promise((resolve) => { 30 | setTimeout(resolve, ms); 31 | }); 32 | 33 | export const removeDynamicData = (entity, payload) => { 34 | const ret = { 35 | ...payload, 36 | }; 37 | 38 | delete ret.createdAt; 39 | delete ret.updatedAt; 40 | 41 | if (entity.name === 'Profile') { 42 | delete ret.registeredAt; 43 | } 44 | 45 | if (entity.name === 'Board') { 46 | delete ret.vip; 47 | } 48 | 49 | return ret; 50 | }; 51 | 52 | export const removeListDynamicData = (entity, payloadList) => { 53 | return payloadList.map((payload) => { 54 | return removeDynamicData(entity, payload); 55 | }); 56 | }; 57 | 58 | export const removeId = (payload) => { 59 | const ret = { 60 | ...payload, 61 | }; 62 | 63 | delete ret.id; 64 | 65 | return ret; 66 | }; 67 | 68 | export const printOnError = (result) => { 69 | if (result.errors) { 70 | // eslint-disable-next-line no-console 71 | result.errors.map(formatGraphQLError).forEach(console.error); 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node: [ '12', '14' ] 12 | 13 | services: 14 | postgres: 15 | image: postgres:9.6 16 | env: 17 | POSTGRES_USER: postgres 18 | POSTGRES_PASSWORD: postgres 19 | POSTGRES_DB: postgres 20 | ports: 21 | - 5432:5432 22 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 23 | 24 | steps: 25 | 26 | - name: Checkout 27 | uses: actions/checkout@master 28 | 29 | - name: Setup Node 30 | uses: actions/setup-node@v2 31 | with: 32 | node-version: ${{ matrix.node }} 33 | 34 | - name: Cache node modules 35 | uses: actions/cache@v2 36 | env: 37 | cache-name: cache-node-modules 38 | with: 39 | # npm cache files are stored in `~/.npm` on Linux/macOS 40 | path: ~/.npm 41 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 42 | restore-keys: | 43 | ${{ runner.os }}-build-${{ env.cache-name }}- 44 | ${{ runner.os }}-build- 45 | ${{ runner.os }}- 46 | 47 | - name: Install dependencies 48 | run: npm install && npm -g install jest 49 | 50 | - name: Test 51 | run: npm run coverage-ci 52 | env: 53 | PGHOST: localhost 54 | PGUSER: postgres 55 | PGPASSWORD: postgres 56 | SHYFT_TEST_DB: postgres 57 | 58 | - name: Build 59 | run: npm run clean && ( npm run build || true ) 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/engine/storage/__snapshots__/StorageType.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`StorageType data type mapping should reject invalid schema data types 1`] = `"Provided schemaDataType is not a valid data type in 'SomeStorageType', got this instead: [object Object]"`; 4 | 5 | exports[`StorageType data type mapping should reject invalid storage data types 1`] = `"Provided storageDataType for 'DataTypeString' is not a valid storage data type in 'SomeStorageType', got this instead: [object Object]"`; 6 | 7 | exports[`StorageType data type mapping should throw on duplicate mappings 1`] = `"Data type mapping for 'DataTypeID' already registered with storage type 'SomeStorageType'"`; 8 | 9 | exports[`StorageType isStorageType should recognize non-StorageType objects 1`] = `"Not an StorageType object"`; 10 | 11 | exports[`StorageType should have a description 1`] = `"Missing description for storage type 'Example'"`; 12 | 13 | exports[`StorageType should have a name 1`] = `"Missing storage type name"`; 14 | 15 | exports[`StorageType should throw on missing data fetching implementations 1`] = `"Storage type 'SomeStorageType' needs to implement findOne()"`; 16 | 17 | exports[`StorageType should throw on missing data fetching implementations 2`] = `"Storage type 'SomeStorageType' needs to implement findOneByValues()"`; 18 | 19 | exports[`StorageType should throw on missing data fetching implementations 3`] = `"Storage type 'SomeStorageType' needs to implement find()"`; 20 | 21 | exports[`StorageType should throw on missing data fetching implementations 4`] = `"Storage type 'SomeStorageType' needs to implement count()"`; 22 | 23 | exports[`StorageType should throw on missing data fetching implementations 5`] = `"Storage type 'SomeStorageType' needs to implement mutate()"`; 24 | -------------------------------------------------------------------------------- /src/storage-connector/util.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | // postgres limitation 4 | export const SYSTEM_NAME_MAX_LENGTH = 63; 5 | 6 | export const generateIndexName = (entityName, attributes, suffix = '_idx') => { 7 | let ret = `${entityName}_${suffix}_`; 8 | const attributesChain = (attributes || []).join('_'); 9 | const attributesSortedChain = (attributes || []).sort().join('__'); 10 | 11 | const hashLength = 10; 12 | const hash = crypto 13 | .createHash('sha256') 14 | .update(attributesSortedChain) 15 | .digest('hex') 16 | .substr(0, hashLength); 17 | 18 | const shortendAttributesChain = attributesChain.substr( 19 | 0, 20 | SYSTEM_NAME_MAX_LENGTH - hashLength - ret.length - 1, 21 | ); 22 | 23 | ret = `${entityName}_${shortendAttributesChain}_${hash}_${suffix}`; 24 | 25 | return ret; 26 | }; 27 | 28 | export const invertDirection = (direction) => { 29 | return direction === 'DESC' ? 'ASC' : 'DESC'; 30 | }; 31 | 32 | export const getLimit = (first, last) => { 33 | if (first >= 0) { 34 | return first; 35 | } else if (last >= 0) { 36 | return last; 37 | } 38 | 39 | return 10; 40 | }; 41 | 42 | export const asyncForEach = async (array, callback) => { 43 | for (let index = 0; index < array.length; index++) { 44 | await callback(array[index], index, array); 45 | } 46 | }; 47 | 48 | export const quote = (item) => { 49 | if (!item) { 50 | throw new Error('quote() requires an input'); 51 | } 52 | 53 | if (item.includes('.')) { 54 | const arr = item.split('.'); 55 | arr[1] = quote(arr[1]); 56 | return arr.join('.'); 57 | } 58 | 59 | // special care for json pointers 60 | if (item.includes('->')) { 61 | const arr = item.split('->'); 62 | arr[0] = quote(arr[0]); 63 | return arr.join('->'); 64 | } 65 | 66 | return `"${item}"`; 67 | }; 68 | -------------------------------------------------------------------------------- /src/storage-connector/permission.ts: -------------------------------------------------------------------------------- 1 | import { CustomError, buildPermissionFilter } from '..'; 2 | 3 | export const PERMISSION_TYPES = { 4 | read: 10, 5 | find: 20, 6 | mutation: 30, 7 | }; 8 | 9 | export const applyPermission = (where, permissionWhere) => { 10 | if (!permissionWhere) { 11 | return where; 12 | } 13 | 14 | const newWhere = { ...where }; 15 | newWhere.$and.push(permissionWhere); 16 | 17 | return newWhere; 18 | }; 19 | 20 | export const loadPermission = (entity, permissionType, entityMutation) => { 21 | if (!entity.permissions) { 22 | return null; 23 | } else if (permissionType === PERMISSION_TYPES.read) { 24 | return entity.permissions.read; 25 | } else if (permissionType === PERMISSION_TYPES.find) { 26 | return entity.permissions.find; 27 | } else if (permissionType === PERMISSION_TYPES.mutation) { 28 | if (!entity.permissions.mutations) { 29 | return null; 30 | } 31 | 32 | const mutationName = entityMutation.name; 33 | return entity.permissions.mutations[mutationName]; 34 | } 35 | 36 | throw new CustomError( 37 | `Unknown permission type '${permissionType}'`, 38 | 'PermissionTypeError', 39 | ); 40 | }; 41 | 42 | export const handlePermission = async ( 43 | context, 44 | entity, 45 | permissionType, 46 | entityMutation?, 47 | input?, 48 | ) => { 49 | const permission = loadPermission(entity, permissionType, entityMutation); 50 | 51 | if (!permission) { 52 | return null; 53 | } 54 | 55 | const { userId, userRoles } = context; 56 | 57 | const permissionFilter = await buildPermissionFilter( 58 | permission, 59 | userId, 60 | userRoles, 61 | entity, 62 | input, 63 | context, 64 | ); 65 | 66 | if (!permissionFilter) { 67 | throw new CustomError('Access denied', 'PermissionError', 403); 68 | } 69 | 70 | return permissionFilter; 71 | }; 72 | -------------------------------------------------------------------------------- /www/src/pages/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires,import/no-unresolved */ 2 | import React from 'react'; 3 | import clsx from 'clsx'; 4 | import Layout from '@theme/Layout'; 5 | import Link from '@docusaurus/Link'; 6 | import Head from '@docusaurus/Head'; 7 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 8 | import styles from './index.module.css'; 9 | import HomepageFeatures from '../components/HomepageFeatures'; 10 | 11 | function HomepageHeader() { 12 | const Svg = require('../../static/img/shyft-logo-white.svg').default; 13 | return ( 14 | <> 15 | <header className={clsx('hero hero--primary', styles.heroBanner)}> 16 | <div className="container"> 17 | <div className="hero"> 18 | <Svg className={styles.logo} alt="Shyft Logo" /> 19 | <h1 className="title">Shyft</h1> 20 | <h2>A server-side framework for building powerful GraphQL APIs</h2> 21 | <div> 22 | <Link className={styles.buttons} to="/intro"> 23 | Getting Started 24 | </Link> 25 | <Link className={styles.buttons} to="/docs/api"> 26 | API reference 27 | </Link> 28 | </div> 29 | </div> 30 | </div> 31 | </header> 32 | <div className={styles.announcement}> 33 | 🚨 Shyft API is still undergoing changes 🚨 34 | </div> 35 | </> 36 | ); 37 | } 38 | 39 | export default function Home() { 40 | const { siteConfig } = useDocusaurusContext(); 41 | return ( 42 | <Layout title={siteConfig.title}> 43 | <Head> 44 | <script async defer src="https://buttons.github.io/buttons.js"></script> 45 | <title /> 46 | </Head> 47 | <HomepageHeader /> 48 | <main> 49 | <HomepageFeatures /> 50 | </main> 51 | </Layout> 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/storage-connector/util.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateIndexName, quote, SYSTEM_NAME_MAX_LENGTH } from './util'; 2 | 3 | describe('util', () => { 4 | describe('quote', () => { 5 | it('should quote attributes', () => { 6 | expect(quote('someAttributeName')).toMatchSnapshot(); 7 | }); 8 | 9 | it('should quote attributes with JSON pointers', () => { 10 | expect(quote("someAttributeName->'someProp'")).toMatchSnapshot(); 11 | }); 12 | 13 | it('should quote fully qualified attributes', () => { 14 | expect(quote('someEntity.someAttributeName')).toMatchSnapshot(); 15 | }); 16 | 17 | it('should quote fully qualified attributes with JSON pointers', () => { 18 | expect( 19 | quote("someEntity.someAttributeName->'someProp'"), 20 | ).toMatchSnapshot(); 21 | }); 22 | }); 23 | 24 | describe('generateIndexName', () => { 25 | it('should generate index names', () => { 26 | expect( 27 | generateIndexName('some_entity', ['first_name', 'last_name']), 28 | ).toMatchSnapshot(); 29 | }); 30 | 31 | it('should generate unique index names based on attribute order', () => { 32 | expect( 33 | generateIndexName('some_entity', ['last_name', 'first_name']), 34 | ).toMatchSnapshot(); 35 | }); 36 | 37 | it('should generate index with custom suffix', () => { 38 | expect( 39 | generateIndexName( 40 | 'some_entity', 41 | ['last_name', 'first_name'], 42 | 'my_suffix', 43 | ), 44 | ).toMatchSnapshot(); 45 | }); 46 | 47 | it(`should limit the total index name length to SYSTEM_NAME_MAX_LENGTH (${SYSTEM_NAME_MAX_LENGTH})`, () => { 48 | const name = generateIndexName('some_entity', [ 49 | 'first_name', 50 | 'last_name', 51 | 'very_long_attribute_name', 52 | 'even_longer_attribute_name', 53 | ]); 54 | 55 | expect(name.length).toEqual(SYSTEM_NAME_MAX_LENGTH); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <p align="center"> 2 | <a href="https://shyft.dev" target="_blank"> 3 | <img 4 | src="https://shyft.dev/img/shyft-logo.svg" 5 | width="150" 6 | /> 7 | </a> 8 | </p> 9 | 10 | # Shyft 11 | 12 | [![Build Status](https://travis-ci.com/chriskalmar/shyft.svg?branch=master)](https://travis-ci.com/chriskalmar/shyft) 13 | [![npm version](https://badge.fury.io/js/shyft.svg)](https://badge.fury.io/js/shyft) 14 | [![codecov](https://codecov.io/gh/chriskalmar/shyft/branch/master/graph/badge.svg)](https://codecov.io/gh/chriskalmar/shyft) 15 | 16 | Shyft is a server-side framework for building powerful GraphQL APIs. 17 | 18 | ## Features 19 | 20 | - convert data model into a GraphQL API 21 | - CRUD query/mutations out of the box 22 | - flexible extension of mutations 23 | - sync data model with database and provide migrations 24 | - complex data fetching with multi-level filters 25 | - offset/limit and cursor-based pagination 26 | - extremely dynamic permission engine based on roles and data lookups 27 | - workflows (finite state machines) with fine-grained control over access and input fields 28 | - extensible with custom queries and mutations (actions) 29 | - internationalization (i18n) included 30 | - generate mock data based on data type or custom functions 31 | - input validation with any validation framework 32 | - derived fields 33 | - hooks (pre- and post-processors) 34 | 35 | ## Install 36 | 37 | With yarn: 38 | 39 | ``` 40 | yarn add shyft 41 | ``` 42 | 43 | or using npm: 44 | 45 | ``` 46 | npm install -S shyft 47 | ``` 48 | 49 | GraphQL is a peer dependency. Install it with: 50 | 51 | ``` 52 | yarn add graphql 53 | ``` 54 | 55 | ## Tests 56 | 57 | Run once: 58 | 59 | ``` 60 | yarn run test 61 | ``` 62 | 63 | Run in watch mode: 64 | 65 | ``` 66 | yarn run test-watch 67 | ``` 68 | 69 | ## Integration Tests 70 | 71 | Run once: 72 | 73 | ``` 74 | yarn run test-integration 75 | ``` 76 | 77 | Run in watch mode: 78 | 79 | ``` 80 | yarn run test-integration-watch 81 | ``` 82 | -------------------------------------------------------------------------------- /src/graphqlProtocol/util.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | 3 | import { 4 | generateTypeName, 5 | generateTypeNamePascalCase, 6 | generateTypeNamePlural, 7 | generateTypeNamePluralPascalCase, 8 | } from './util'; 9 | 10 | describe('util', () => { 11 | describe('type name', () => { 12 | it('should generate a type name', () => { 13 | const result1 = generateTypeName('geoCountry'); 14 | const result2 = generateTypeName('geo_country'); 15 | const result3 = generateTypeName('GEO_COUNTRY'); 16 | 17 | expect(result1).toEqual('geoCountry'); 18 | expect(result2).toEqual('geoCountry'); 19 | expect(result3).toEqual('geoCountry'); 20 | }); 21 | 22 | it('should generate pascal case type names', () => { 23 | const result1 = generateTypeNamePascalCase('geoCountry'); 24 | const result2 = generateTypeNamePascalCase('geo_country'); 25 | const result3 = generateTypeNamePascalCase('GEO_COUNTRY'); 26 | 27 | expect(result1).toEqual('GeoCountry'); 28 | expect(result2).toEqual('GeoCountry'); 29 | expect(result3).toEqual('GeoCountry'); 30 | }); 31 | 32 | it('should generate pluralized type names', () => { 33 | const result1 = generateTypeNamePlural('geoCountry'); 34 | const result2 = generateTypeNamePlural('geo_country'); 35 | const result3 = generateTypeNamePlural('GEO_COUNTRY'); 36 | 37 | expect(result1).toEqual('geoCountries'); 38 | expect(result2).toEqual('geoCountries'); 39 | expect(result3).toEqual('geoCountries'); 40 | }); 41 | 42 | it('should generate pluralized pascal case type names', () => { 43 | const result1 = generateTypeNamePluralPascalCase('geoCountry'); 44 | const result2 = generateTypeNamePluralPascalCase('geo_country'); 45 | const result3 = generateTypeNamePluralPascalCase('GEO_COUNTRY'); 46 | 47 | expect(result1).toEqual('GeoCountries'); 48 | expect(result2).toEqual('GeoCountries'); 49 | expect(result3).toEqual('GeoCountries'); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/data/boards.csv: -------------------------------------------------------------------------------- 1 | Reiciendis quaerat,37,0,103,, 2 | Et eum,46,1,62,, 3 | Occaecati adipisci vel,79,1,50,, 4 | Delectus qui maxime,42,0,10,{"description": "Fuga facilis"},["Jane"\, "Martha"\, "Tom"] 5 | Cumque impedit est,55,1,4,, 6 | Ut et,42,1,102,, 7 | Vel ullam,72,1,9,, 8 | Illo sit quia,59,1,21,, 9 | Veritatis nihil cum,52,1,61,, 10 | Sit aut,71,1,73,, 11 | Aut incidunt consequatur,81,0,12,, 12 | Tenetur porro,23,0,83,, 13 | Suscipit aut cum,84,0,89,,["John"\, "Tom"\, "Jane"] 14 | Id cum,26,0,26,, 15 | Sunt nobis,60,0,72,, 16 | Est adipisci inventore,73,0,14,, 17 | Est et,24,0,55,, 18 | In ipsum est,75,0,57,, 19 | Rerum ad,48,0,71,,["Jane"\, "Josh"\, "Jack"\, "Bart"] 20 | Sunt quo,10,0,59,, 21 | Quia sapiente,62,0,93,{"description": "Qui fnecessitatibus"}, 22 | Excepturi amet animi,88,1,22,, 23 | Aut molestiae,12,0,51,, 24 | Non iusto unde,30,1,85,, 25 | Libero similique vitae,38,0,91,,[] 26 | Blanditiis cumque nisi,29,0,65,, 27 | Mollitia qui,14,0,45,, 28 | Deleniti esse itaque,13,0,48,{"description": "Necessitatibus facilis blanditiis"},["Bart"] 29 | Ut provident assumenda,73,0,42,, 30 | Fuga accusantium voluptatem,6,0,19,, 31 | Culpa facilis,20,0,15,, 32 | Aliquam voluptates,78,1,82,, 33 | Suscipit quas,47,1,96,, 34 | Voluptas dolor,71,0,69,{"description": "Consequatur facilis"},["Martha"\, "Tom"] 35 | Voluptatum necessitatibus molestias,17,0,3,, 36 | Consectetur blanditiis consequatur,40,0,81,, 37 | Voluptate tempora veritatis,42,0,36,, 38 | Nesciunt nihil enim,37,0,52,{"description": "Est voluptates"\, "externalLinks": {"web": "https://example.com"}}, 39 | Expedita qui,61,1,41,, 40 | Dignissimos est perferendis,65,0,107,, 41 | Enim dolore,27,0,17,{"externalLinks": {"web": "https://example.com"}}, 42 | Quo ut rerum,37,1,7,,["Ben"\, "Mark"\, "Jane"] 43 | Sapiente laborum non,61,1,70,{"externalLinks": {"web": "https://example.com"}},["John"\, "Jack"] 44 | Qui rem ut,9,0,29,, 45 | Sed necessitatibus facilis,85,1,74,{}, 46 | Et architecto,63,1,109,, 47 | Accusamus sequi,23,0,32,,["Phil"\, "Jane"\, "Jack"] 48 | Nobis totam,41,1,35,, 49 | Odit qui,26,1,101,, 50 | Sed assumenda repellendus,84,1,16,, 51 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | services: 13 | postgres: 14 | image: postgres:9.6 15 | env: 16 | POSTGRES_USER: postgres 17 | POSTGRES_PASSWORD: postgres 18 | POSTGRES_DB: postgres 19 | ports: 20 | - 5432:5432 21 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@master 26 | 27 | - name: Setup Node 28 | uses: actions/setup-node@v2 29 | with: 30 | node-version: '12.x' 31 | 32 | - name: Cache node modules 33 | uses: actions/cache@v2 34 | env: 35 | cache-name: cache-node-modules 36 | with: 37 | # npm cache files are stored in `~/.npm` on Linux/macOS 38 | path: ~/.npm 39 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 40 | restore-keys: | 41 | ${{ runner.os }}-build-${{ env.cache-name }}- 42 | ${{ runner.os }}-build- 43 | ${{ runner.os }}- 44 | 45 | - name: Install dependencies 46 | run: npm install && npm -g install jest 47 | 48 | - name: Test 49 | run: npm run coverage-ci 50 | env: 51 | PGHOST: localhost 52 | PGUSER: postgres 53 | PGPASSWORD: postgres 54 | SHYFT_TEST_DB: postgres 55 | 56 | - name: Build 57 | run: npm run clean && ( npm run build || true ) 58 | 59 | - name: Create .npmrc 60 | run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 61 | env: 62 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 63 | 64 | - name: Publish to npm 65 | run: npm publish --access public 66 | 67 | - name: Generate docs and build website 68 | run: cd wwww && npm install && npm run build 69 | 70 | # TODO: needs a website deployment step here 71 | 72 | 73 | -------------------------------------------------------------------------------- /test/models/BoardMemberView.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ViewEntity, 3 | Permission, 4 | DataTypeString, 5 | DataTypeInteger, 6 | buildListDataType, 7 | } from '../../src'; 8 | 9 | import { Profile } from './Profile'; 10 | import { Board } from './Board'; 11 | 12 | const readPermissions = () => [ 13 | new Permission().role('admin'), 14 | new Permission().userAttribute('inviterId'), 15 | new Permission().lookup(Board, { 16 | id: 'boardId', 17 | owner: ({ userId }) => userId, 18 | }), 19 | ]; 20 | 21 | export const BoardMemberView = new ViewEntity({ 22 | name: 'BoardMemberView', 23 | description: 'Custom view of board members', 24 | 25 | permissions: { 26 | read: readPermissions(), 27 | find: readPermissions(), 28 | }, 29 | 30 | viewExpression: ` 31 | SELECT 32 | b.id AS "boardId", 33 | b.name AS "boardName", 34 | p.id AS "inviterId", 35 | p.username AS "username", 36 | count(*) AS "inviteCount", 37 | array_agg(bm.invitee) AS "invitees" 38 | FROM board_member bm 39 | JOIN profile p 40 | ON (bm.inviter = p.id) 41 | JOIN board b 42 | ON (bm.board = b.id) 43 | GROUP BY 1, 2, 3, 4 44 | ORDER BY 1, 3 45 | `, 46 | 47 | attributes: { 48 | boardId: { 49 | type: Board, 50 | description: 'Reference to the board', 51 | required: true, 52 | }, 53 | 54 | boardName: { 55 | type: DataTypeString, 56 | description: 'Name of the board', 57 | required: true, 58 | }, 59 | 60 | inviterId: { 61 | type: Profile, 62 | description: 'The user that invites to a board', 63 | required: true, 64 | }, 65 | 66 | username: { 67 | type: DataTypeString, 68 | description: 'Username of the inviter', 69 | required: true, 70 | }, 71 | 72 | inviteCount: { 73 | type: DataTypeInteger, 74 | description: 'Number of invitees', 75 | required: true, 76 | }, 77 | 78 | invitees: { 79 | type: buildListDataType({ 80 | itemType: Profile, 81 | }), 82 | description: 'List of invitees', 83 | required: true, 84 | }, 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /src/engine/datatype/DataTypeState.ts: -------------------------------------------------------------------------------- 1 | import { uniq } from 'lodash'; 2 | import { passOrThrow, isMap } from '../util'; 3 | import { stateNameRegex, STATE_NAME_PATTERN } from '../constants'; 4 | import { DataType } from './DataType'; 5 | 6 | export type StateType = { 7 | [key: string]: number; 8 | }; 9 | 10 | export type DataTypeStateSetupType = { 11 | name: string; 12 | description?: string; 13 | states: StateType; 14 | }; 15 | 16 | export class DataTypeState extends DataType { 17 | states: StateType; 18 | constructor(setup: DataTypeStateSetupType = {} as DataTypeStateSetupType) { 19 | const { name, description, states } = setup; 20 | 21 | passOrThrow( 22 | isMap(states, true), 23 | () => `'Missing states for data type '${name}'`, 24 | ); 25 | 26 | const stateNames = Object.keys(states); 27 | const uniqueIds = []; 28 | 29 | stateNames.map((stateName) => { 30 | const stateId = states[stateName]; 31 | uniqueIds.push(stateId); 32 | 33 | passOrThrow( 34 | stateNameRegex.test(stateName), 35 | () => 36 | `Invalid state name '${stateName}' for data type '${name}' (Regex: /${STATE_NAME_PATTERN}/)`, 37 | ); 38 | 39 | passOrThrow( 40 | stateId === Number(stateId) && stateId > 0, 41 | () => 42 | `State '${stateName}' for data type '${name}' has an invalid unique ID (needs to be a positive integer)`, 43 | ); 44 | }); 45 | 46 | passOrThrow( 47 | uniqueIds.length === uniq(uniqueIds).length, 48 | () => 49 | `Each state defined for data type '${name}' needs to have a unique ID`, 50 | ); 51 | 52 | super({ 53 | name, 54 | description: description || `States: ${stateNames.join(', ')}`, 55 | enforceIndex: true, 56 | mock() { 57 | const randomPos = Math.floor(Math.random() * uniqueIds.length); 58 | return uniqueIds[randomPos]; 59 | }, 60 | }); 61 | 62 | this.states = states; 63 | } 64 | 65 | toString(): string { 66 | return this.name; 67 | } 68 | } 69 | 70 | export const isDataTypeState = (obj: unknown): obj is DataTypeState => { 71 | return obj instanceof DataTypeState; 72 | }; 73 | -------------------------------------------------------------------------------- /src/engine/datatype/DataTypeEnum.ts: -------------------------------------------------------------------------------- 1 | import { uniq } from 'lodash'; 2 | import { passOrThrow, isMap } from '../util'; 3 | import { enumValueRegex, ENUM_VALUE_PATTERN } from '../constants'; 4 | import { DataType } from './DataType'; 5 | 6 | export type ValueType = { 7 | [key: string]: number; 8 | }; 9 | 10 | export type DataTypeEnumSetupType = { 11 | name: string; 12 | description?: string; 13 | values: ValueType; 14 | }; 15 | 16 | export class DataTypeEnum extends DataType { 17 | values: ValueType; 18 | 19 | constructor(setup: DataTypeEnumSetupType = {} as DataTypeEnumSetupType) { 20 | const { name, description, values } = setup; 21 | 22 | passOrThrow( 23 | isMap(values, true), 24 | () => `'Missing enum values for data type '${name}'`, 25 | ); 26 | 27 | const valueNames = Object.keys(values); 28 | const uniqueIds = []; 29 | 30 | valueNames.map((valueName) => { 31 | const valueId = values[valueName]; 32 | uniqueIds.push(valueId); 33 | 34 | passOrThrow( 35 | enumValueRegex.test(valueName), 36 | () => 37 | `Invalid value name '${valueName}' for data type '${name}' (Regex: /${ENUM_VALUE_PATTERN}/)`, 38 | ); 39 | 40 | passOrThrow( 41 | valueId === Number(valueId) && valueId > 0, 42 | () => 43 | `Value '${valueName}' for data type '${name}' has an invalid unique ID (needs to be a positive integer)`, 44 | ); 45 | }); 46 | 47 | passOrThrow( 48 | uniqueIds.length === uniq(uniqueIds).length, 49 | () => 50 | `Each value defined for data type '${name}' needs to have a unique ID`, 51 | ); 52 | 53 | super({ 54 | name, 55 | description: description || `Enumeration set: ${valueNames.join(', ')}`, 56 | enforceIndex: true, 57 | mock() { 58 | const randomPos = Math.floor(Math.random() * uniqueIds.length); 59 | return uniqueIds[randomPos]; 60 | }, 61 | }); 62 | 63 | this.values = values; 64 | } 65 | 66 | toString(): string { 67 | return this.name; 68 | } 69 | } 70 | 71 | export const isDataTypeEnum = (obj: unknown): obj is DataTypeEnum => { 72 | return obj instanceof DataTypeEnum; 73 | }; 74 | -------------------------------------------------------------------------------- /src/graphqlProtocol/registry.ts: -------------------------------------------------------------------------------- 1 | import { ViewEntity } from '..'; 2 | import { Entity } from '../engine/entity/Entity'; 3 | 4 | interface Data { 5 | [key: string]: unknown; 6 | } 7 | 8 | export interface RegistryEntityAttribute { 9 | fieldName: string; 10 | fieldNameI18n: string; 11 | fieldNameI18nJson: string; 12 | } 13 | 14 | export interface RegistryEntityAttributes { 15 | [key: string]: RegistryEntityAttribute; 16 | } 17 | 18 | export interface RegistryEntity { 19 | entity: Entity | ViewEntity; 20 | typeName: string; 21 | typeNamePlural: string; 22 | typeNamePascalCase: string; 23 | typeNamePluralPascalCase: string; 24 | attributes: RegistryEntityAttributes; 25 | dataShaper: (data: Data) => Data; 26 | dataSetShaper: (set: Data[]) => Data[]; 27 | reverseDataShaper: (data: Data) => Data; 28 | } 29 | 30 | export interface Registry { 31 | [key: string]: RegistryEntity; 32 | } 33 | 34 | export const registry: Registry = {}; 35 | 36 | export const registerEntity = ({ 37 | entity, 38 | typeName, 39 | typeNamePlural, 40 | typeNamePascalCase, 41 | typeNamePluralPascalCase, 42 | attributes, 43 | dataShaper, 44 | dataSetShaper, 45 | reverseDataShaper, 46 | }: RegistryEntity) => { 47 | const entityName = entity.name; 48 | 49 | registry[entityName] = { 50 | entity, 51 | typeName, 52 | typeNamePlural, 53 | typeNamePascalCase, 54 | typeNamePluralPascalCase, 55 | attributes, 56 | dataShaper, 57 | dataSetShaper, 58 | reverseDataShaper, 59 | }; 60 | }; 61 | 62 | export const getRegisteredEntity = (entityName: string): RegistryEntity => { 63 | if (!registry[entityName]) { 64 | throw new Error(`Cannot find entity '${entityName}' in registry`); 65 | } 66 | 67 | return registry[entityName]; 68 | }; 69 | 70 | export const getRegisteredEntityAttribute = ( 71 | entityName: string, 72 | attributeName: string, 73 | ): RegistryEntityAttribute => { 74 | const entity = getRegisteredEntity(entityName); 75 | 76 | if (!entity.attributes[attributeName]) { 77 | throw new Error( 78 | `Cannot find entity attribute '${entityName}.${attributeName}' in registry`, 79 | ); 80 | } 81 | 82 | return entity.attributes[attributeName]; 83 | }; 84 | -------------------------------------------------------------------------------- /test/models/Book.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | DataTypeString, 4 | buildListDataType, 5 | buildObjectDataType, 6 | Permission, 7 | } from '../../src'; 8 | 9 | export const Book = new Entity({ 10 | name: 'Book', 11 | description: 'A book', 12 | 13 | permissions: { 14 | read: new Permission().everyone(), 15 | find: new Permission().everyone(), 16 | }, 17 | 18 | attributes: { 19 | title: { 20 | type: DataTypeString, 21 | description: 'Book title', 22 | required: true, 23 | i18n: true, 24 | }, 25 | 26 | shortSummary: { 27 | type: DataTypeString, 28 | description: 'Book summary', 29 | i18n: true, 30 | }, 31 | 32 | author: { 33 | type: DataTypeString, 34 | description: 'Author of the book', 35 | required: true, 36 | }, 37 | 38 | reviews: { 39 | type: buildListDataType({ 40 | itemType: buildObjectDataType({ 41 | attributes: { 42 | reviewer: { 43 | type: DataTypeString, 44 | description: 'name of the reviewer', 45 | required: true, 46 | }, 47 | reviewText: { 48 | description: 'the review text', 49 | required: true, 50 | type: DataTypeString, 51 | }, 52 | bookAttributes: { 53 | description: 'attributes of the book given by the reviewer', 54 | required: true, 55 | type: buildListDataType({ 56 | itemType: buildObjectDataType({ 57 | attributes: { 58 | attribute: { 59 | type: DataTypeString, 60 | description: 'attribute describing the book', 61 | required: true, 62 | }, 63 | value: { 64 | type: DataTypeString, 65 | description: 'attribute value describing the book', 66 | required: true, 67 | }, 68 | }, 69 | }), 70 | }), 71 | }, 72 | }, 73 | }), 74 | }), 75 | description: 'Book reviews', 76 | }, 77 | }, 78 | }); 79 | -------------------------------------------------------------------------------- /src/engine/datatype/DataType.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | 3 | import { DataType, DataTypeSetup, isDataType } from './DataType'; 4 | 5 | import { passOrThrow } from '../util'; 6 | 7 | import { DataTypeBoolean, DataTypeString } from './dataTypes'; 8 | 9 | describe('DataType', () => { 10 | it('should have a name', () => { 11 | function fn() { 12 | new DataType(); // eslint-disable-line no-new 13 | } 14 | 15 | expect(fn).toThrowErrorMatchingSnapshot(); 16 | }); 17 | 18 | it('should have a description', () => { 19 | function fn() { 20 | // eslint-disable-next-line no-new 21 | new DataType({ 22 | name: 'example', 23 | } as DataTypeSetup); 24 | } 25 | 26 | expect(fn).toThrowErrorMatchingSnapshot(); 27 | }); 28 | 29 | it('should provide a mock function', () => { 30 | function fn() { 31 | // eslint-disable-next-line no-new 32 | new DataType({ 33 | name: 'example', 34 | description: 'Just some description', 35 | }); 36 | } 37 | 38 | expect(fn).toThrowErrorMatchingSnapshot(); 39 | }); 40 | 41 | it("should return it's name", () => { 42 | const dataType = new DataType({ 43 | name: 'someDataTypeName', 44 | description: 'Just some description', 45 | mock() {}, 46 | }); 47 | 48 | expect(dataType.name).toBe('someDataTypeName'); 49 | expect(String(dataType)).toBe('someDataTypeName'); 50 | }); 51 | 52 | it.skip('should have a unique name', () => {}); 53 | 54 | describe('isDataType', () => { 55 | it('should recognize objects of type DataType', () => { 56 | function fn() { 57 | passOrThrow( 58 | isDataType(DataTypeBoolean) && isDataType(DataTypeString), 59 | () => 'This error will never happen', 60 | ); 61 | } 62 | 63 | expect(fn).not.toThrow(); 64 | }); 65 | 66 | it('should recognize non-DataType objects', () => { 67 | function fn() { 68 | passOrThrow( 69 | isDataType({}) || isDataType(function test() {}) || isDataType(Error), 70 | () => 'Not a DataType object', 71 | ); 72 | } 73 | 74 | expect(fn).toThrowErrorMatchingSnapshot(); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/models/Message.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Mutation, 4 | MUTATION_TYPE_CREATE, 5 | Permission, 6 | DataTypeString, 7 | DataTypeTimestampTz, 8 | } from '../../src'; 9 | 10 | import { Profile } from './Profile'; 11 | import { Board } from './Board'; 12 | import { BoardMember } from './BoardMember'; 13 | 14 | const readPermissions = [ 15 | new Permission().role('admin'), 16 | new Permission().userAttribute('author'), 17 | new Permission() 18 | .lookup(BoardMember, { 19 | board: 'board', 20 | invitee: ({ userId }) => userId, 21 | state: () => ['joined', 'accepted'], 22 | }) 23 | .lookup(Board, { 24 | id: 'board', 25 | owner: ({ userId }) => userId, 26 | }), 27 | ]; 28 | 29 | const writePermissions = [ 30 | new Permission().role('admin'), 31 | new Permission() 32 | .lookup(BoardMember, { 33 | board: ({ input }) => input.board, 34 | invitee: ({ userId }) => userId, 35 | state: () => ['joined', 'accepted'], 36 | }) 37 | .lookup(Board, { 38 | id: ({ input }) => input.board, 39 | owner: ({ userId }) => userId, 40 | }), 41 | ]; 42 | 43 | export const Message = new Entity({ 44 | name: 'Message', 45 | description: 'Chat message in a board', 46 | 47 | includeTimeTracking: true, 48 | 49 | mutations: [ 50 | new Mutation({ 51 | name: 'write', 52 | description: 'write a message', 53 | type: MUTATION_TYPE_CREATE, 54 | attributes: ['board', 'content', 'writtenAt', 'author'], 55 | }), 56 | ], 57 | 58 | permissions: { 59 | read: readPermissions, 60 | find: readPermissions, 61 | mutations: { 62 | write: writePermissions, 63 | }, 64 | }, 65 | 66 | attributes: { 67 | board: { 68 | type: Board, 69 | description: 'Reference to the board', 70 | required: true, 71 | }, 72 | 73 | author: { 74 | type: Profile, 75 | description: 'The user that writes the message', 76 | required: true, 77 | }, 78 | 79 | content: { 80 | type: DataTypeString, 81 | description: 'Message content', 82 | required: true, 83 | }, 84 | 85 | writtenAt: { 86 | type: DataTypeTimestampTz, 87 | description: 'Message timestamp', 88 | required: true, 89 | }, 90 | }, 91 | }); 92 | -------------------------------------------------------------------------------- /src/graphqlProtocol/helper.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { ProtocolGraphQL } from './ProtocolGraphQL'; 3 | import { ProtocolGraphQLConfiguration } from './ProtocolGraphQLConfiguration'; 4 | 5 | import { INDEX_UNIQUE } from '../engine/index/Index'; 6 | import { CustomError } from '../engine/CustomError'; 7 | import { Entity, Mutation, Subscription } from '..'; 8 | import { getRegisteredEntityAttribute } from './registry'; 9 | 10 | export const getEntityUniquenessAttributes = (entity) => { 11 | const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; 12 | 13 | const ret = []; 14 | 15 | if (!entity.getIndexes) { 16 | return ret; 17 | } 18 | 19 | const entityIndexes = entity.getIndexes(); 20 | 21 | if (entityIndexes) { 22 | entityIndexes.map(({ type, attributes }) => { 23 | if (type === INDEX_UNIQUE) { 24 | ret.push({ 25 | uniquenessName: protocolConfiguration.generateUniquenessAttributesName( 26 | entity, 27 | attributes, 28 | ), 29 | attributes, 30 | }); 31 | } 32 | }); 33 | } 34 | 35 | return ret; 36 | }; 37 | 38 | export const checkRequiredI18nInputs = ( 39 | entity: Entity | any, 40 | operation: Mutation | Subscription, 41 | input: any, 42 | ) => { 43 | const entityAttributes = entity.getAttributes(); 44 | 45 | _.forEach(operation.attributes, (attributeName) => { 46 | const attribute = entityAttributes[attributeName]; 47 | const { 48 | fieldName: gqlFieldName, 49 | fieldNameI18n: gqlFieldNameI18n, 50 | } = getRegisteredEntityAttribute(entity.name, attribute.name); 51 | 52 | if (attribute.i18n) { 53 | if ( 54 | input[gqlFieldName] && 55 | input[gqlFieldNameI18n] && 56 | input[gqlFieldNameI18n] 57 | ) { 58 | throw new CustomError( 59 | `Only one of these fields may be used: ${gqlFieldName}, ${gqlFieldNameI18n}`, 60 | 'AmbiguousI18nInputError', 61 | ); 62 | } 63 | 64 | if (attribute.required && !operation.ignoreRequired) { 65 | if (!input[gqlFieldName] && !input[gqlFieldNameI18n]) { 66 | throw new CustomError( 67 | `Provide one of these fields: ${gqlFieldName}, ${gqlFieldNameI18n}`, 68 | 'MissingI18nInputError', 69 | ); 70 | } 71 | } 72 | } 73 | }); 74 | }; 75 | -------------------------------------------------------------------------------- /test/models/DataTypeTester.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | DataTypeID, 4 | DataTypeInteger, 5 | DataTypeBigInt, 6 | DataTypeFloat, 7 | DataTypeBoolean, 8 | DataTypeString, 9 | DataTypeJson, 10 | DataTypeTimestamp, 11 | DataTypeTimestampTz, 12 | DataTypeDate, 13 | DataTypeTime, 14 | DataTypeTimeTz, 15 | DataTypeUUID, 16 | } from '../../src'; 17 | 18 | export const DataTypeTester = new Entity({ 19 | name: 'DataTypeTester', 20 | description: 'An entity with all available data types', 21 | 22 | attributes: { 23 | typeId: { 24 | type: DataTypeID, 25 | description: 'Field of type DataTypeID', 26 | required: true, 27 | }, 28 | typeInteger: { 29 | type: DataTypeInteger, 30 | description: 'Field of type DataTypeInteger', 31 | required: true, 32 | }, 33 | typeBigInt: { 34 | type: DataTypeBigInt, 35 | description: 'Field of type DataTypeBigInt', 36 | required: true, 37 | }, 38 | typeFloat: { 39 | type: DataTypeFloat, 40 | description: 'Field of type DataTypeFloat', 41 | required: true, 42 | }, 43 | typeBoolean: { 44 | type: DataTypeBoolean, 45 | description: 'Field of type DataTypeBoolean', 46 | required: true, 47 | }, 48 | typeString: { 49 | type: DataTypeString, 50 | description: 'Field of type DataTypeString', 51 | required: true, 52 | }, 53 | typeJson: { 54 | type: DataTypeJson, 55 | description: 'Field of type DataTypeJson', 56 | required: true, 57 | }, 58 | typeTimestamp: { 59 | type: DataTypeTimestamp, 60 | description: 'Field of type DataTypeTimestamp', 61 | required: true, 62 | }, 63 | typeTimestampTz: { 64 | type: DataTypeTimestampTz, 65 | description: 'Field of type DataTypeTimestampTz', 66 | required: true, 67 | }, 68 | typeDate: { 69 | type: DataTypeDate, 70 | description: 'Field of type DataTypeDate', 71 | required: true, 72 | }, 73 | typeTime: { 74 | type: DataTypeTime, 75 | description: 'Field of type DataTypeTime', 76 | required: true, 77 | }, 78 | typeTimeTz: { 79 | type: DataTypeTimeTz, 80 | description: 'Field of type DataTypeTimeTz', 81 | required: true, 82 | }, 83 | typeUuid: { 84 | type: DataTypeUUID, 85 | description: 'Field of type DataTypeUUID', 86 | required: true, 87 | }, 88 | }, 89 | }); 90 | -------------------------------------------------------------------------------- /src/graphqlProtocol/__snapshots__/filter.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`filter splitAttributeAndFilterOperator should fail on wrong inputs 1`] = `"invalid filter 'undefined'"`; 4 | 5 | exports[`filter splitAttributeAndFilterOperator should fail on wrong inputs 2`] = `"invalid filter ''"`; 6 | 7 | exports[`filter splitAttributeAndFilterOperator should fail on wrong inputs 3`] = `"invalid filter '[object Object]'"`; 8 | 9 | exports[`filter splitAttributeAndFilterOperator should fail on wrong inputs 4`] = `"invalid filter 'name__'"`; 10 | 11 | exports[`filter splitAttributeAndFilterOperator should fail on wrong inputs 5`] = `"invalid filter 'name___'"`; 12 | 13 | exports[`filter transformFilterLevel should throw if exact match operators is used with another operator on the same attribute 1`] = `"Cannot combine 'exact match' operator with other operators on attribute 'firstName' used in filter"`; 14 | 15 | exports[`filter transformFilterLevel should throw if exact match operators is used with another operator on the same attribute 2`] = `"Cannot combine 'exact match' operator with other operators on attribute 'firstName' used in filter"`; 16 | 17 | exports[`filter transformFilterLevel should throw if invalid attributes are used in filter 1`] = `"Unknown attribute name 'something' used in filter"`; 18 | 19 | exports[`filter transformFilterLevel should throw if invalid attributes are used in filter 2`] = `"Unknown attribute name 'anything_here' used in filter"`; 20 | 21 | exports[`filter transformFilterLevel should throw if invalid attributes are used in filter 3`] = `"Unknown attribute name 'anything_here' used in filter at 'just.here'"`; 22 | 23 | exports[`filter transformFilterLevel should throw if provided params are invalid 1`] = `"filter needs to be an object of filter criteria"`; 24 | 25 | exports[`filter transformFilterLevel should throw if provided params are invalid 2`] = `"optional path in transformFilterLevel() needs to be an array"`; 26 | 27 | exports[`filter transformFilterLevel should throw if provided params are invalid 3`] = `"filter at 'somewhere' needs to be an object of filter criteria"`; 28 | 29 | exports[`filter transformFilterLevel should throw if provided params are invalid 4`] = `"filter at 'somewhere.deeply.nested' needs to be an object of filter criteria"`; 30 | 31 | exports[`filter transformFilterLevel should throw if provided params are invalid 5`] = `"transformFilterLevel() expects an attribute map"`; 32 | -------------------------------------------------------------------------------- /src/engine/entity/__snapshots__/ViewEntity.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ViewEntity attributes should catch invalid attribute names 1`] = `"Invalid attribute name 'wrong-named-attribute' in view entity 'SomeViewEntityName' (Regex: /^[a-zA-Z][a-zA-Z0-9_]*$/)"`; 4 | 5 | exports[`ViewEntity attributes should reject an empty attributes map 1`] = `"ViewEntity 'SomeViewEntityName' has no attributes defined"`; 6 | 7 | exports[`ViewEntity attributes should reject attributes with missing or invalid data type 1`] = `"'SomeViewEntityName.someAttribute' has invalid data type 'undefined'"`; 8 | 9 | exports[`ViewEntity attributes should reject attributes without a description 1`] = `"Missing description for 'SomeViewEntityName.someAttribute'"`; 10 | 11 | exports[`ViewEntity attributes should throw if provided with an invalid resolve function 1`] = `"'SomeViewEntityName.someAttribute' has an invalid resolve function'"`; 12 | 13 | exports[`ViewEntity isViewEntity should recognize non-ViewEntity objects 1`] = `"Not a ViewEntity object"`; 14 | 15 | exports[`ViewEntity permissions should throw if empty permissions are provided 1`] = `"ViewEntity 'SomeViewEntityName' has one or more empty permission definitions in: read, find, mutations.delete"`; 16 | 17 | exports[`ViewEntity references should throw if invalid attribute is to be referenced 1`] = `"Cannot reference attribute 'Country.notHere' as it does not exist"`; 18 | 19 | exports[`ViewEntity should accept only maps or functions as attributes definition 1`] = `"'attributes' for view entity 'Example' needs to be a map of attributes or a function returning a map of attributes"`; 20 | 21 | exports[`ViewEntity should have a description 1`] = `"Missing description for view entity 'Example'"`; 22 | 23 | exports[`ViewEntity should have a map of attributes 1`] = `"'attributes' for view entity 'Example' needs to be a map of attributes or a function returning a map of attributes"`; 24 | 25 | exports[`ViewEntity should have a name 1`] = `"Missing view entity name"`; 26 | 27 | exports[`ViewEntity should have a view expression 1`] = `"Missing viewExpression for view entity 'Example'"`; 28 | 29 | exports[`ViewEntity should reject non-map results of attribute definition functions 1`] = `"Attribute definition function for view entity 'Example' does not return a map"`; 30 | 31 | exports[`ViewEntity should throw if invalid storage type was provided 1`] = `"ViewEntity 'Example' needs a valid storage type (defaults to 'StorageTypeNull')"`; 32 | -------------------------------------------------------------------------------- /www/static/img/shyft-logo-white-small.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 105 105" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g><path d="M52.118,3.095l42.455,24.511l0,49.023l-42.455,24.512l-42.455,-24.512l0,-49.023l42.455,-24.511Z" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M52.118,9.663l36.767,21.227l0,42.455l-36.767,21.228l-36.767,-21.228l0,-42.455l36.767,-21.227Z" style="fill:none;stroke:#fff;stroke-width:2px;"/><g><path d="M83.197,34.174l-62.159,35.887" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M36.57,25.207l-3.138,1.812" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M28.799,29.694l-7.761,4.48" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M83.197,43.146l-14.325,8.27" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M64.133,54.153l-8.057,4.651" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M40.447,31.94l-7.746,4.472" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M27.962,39.149l-6.924,3.997" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M83.197,52.118l-1.68,0.97" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M76.764,55.824l-10.225,5.854" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M61.899,64.414l-1.987,1.147" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M44.364,38.651l-2.573,1.486" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M37.051,42.873l-8.694,5.019" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M24.213,50.285l-3.175,1.833" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M83.197,61.09l-9.052,5.226" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M69.423,69.042l-5.594,3.23" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M48.241,45.384l-9.964,5.753" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M33.538,53.873l-5.218,3.013" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M23.618,59.601l-2.58,1.489" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M83.197,70.061l-1.615,0.933" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M77.212,73.517c-1.015,0.591 0.537,-0.267 -3.365,1.943" style="fill:none;stroke:#fff;stroke-width:2px;"/><path d="M69.107,78.196l-1.401,0.809" style="fill:none;stroke:#fff;stroke-width:2px;"/></g></g></svg> 2 | -------------------------------------------------------------------------------- /www/static/img/shyft-logo-white.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 596 596" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g><path d="M297.638,9.317l249.693,144.16l0,288.321l-249.693,144.16l-249.693,-144.16l0,-288.321l249.693,-144.16Z" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M297.638,47.945l216.24,124.846l0,249.693l-216.24,124.847l-216.241,-124.847l0,-249.693l216.241,-124.846Z" style="fill:none;stroke:#fff;stroke-width:7px;"/><g><path d="M480.426,192.105l-365.576,211.065" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M206.198,139.365l-18.456,10.656" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M160.49,165.755l-45.64,26.35" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M480.426,244.871l-84.248,48.641" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M368.303,309.605l-47.385,27.358" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M228.998,178.968l-45.554,26.301" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M155.57,221.362l-40.72,23.509" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M480.426,297.638l-9.879,5.704" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M442.592,319.435l-60.141,34.429" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M355.166,369.957l-11.688,6.748" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M252.038,218.432l-15.138,8.74" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M209.026,243.265l-51.133,29.522" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M133.519,286.859l-18.669,10.779" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M480.426,350.404l-53.241,30.739" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M399.417,397.174l-32.899,18.995" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M274.838,258.035l-58.6,33.833" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M188.364,307.961l-30.692,17.72" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M130.019,341.646l-15.169,8.758" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M480.426,403.17l-9.502,5.486" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M445.226,423.493c-5.972,3.477 3.159,-1.57 -19.794,11.428" style="fill:none;stroke:#fff;stroke-width:7px;"/><path d="M397.558,451.014l-8.24,4.758" style="fill:none;stroke:#fff;stroke-width:7px;"/></g></g></svg> 2 | -------------------------------------------------------------------------------- /misc/shyft-logo.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 596 596" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><path d="M297.638,9.317l249.693,144.16l0,288.321l-249.693,144.16l-249.693,-144.16l0,-288.321l249.693,-144.16Z" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M297.638,47.945l216.24,124.846l0,249.693l-216.24,124.847l-216.241,-124.847l0,-249.693l216.241,-124.846Z" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><g><path d="M480.426,192.105l-365.576,211.065" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M206.198,139.365l-18.456,10.656" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M160.49,165.755l-45.64,26.35" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M480.426,244.871l-84.248,48.641" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M368.303,309.605l-47.385,27.358" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M228.998,178.968l-45.554,26.301" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M155.57,221.362l-40.72,23.509" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M480.426,297.638l-9.879,5.704" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M442.592,319.435l-60.141,34.429" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M355.166,369.957l-11.688,6.748" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M252.038,218.432l-15.138,8.74" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M209.026,243.265l-51.133,29.522" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M133.519,286.859l-18.669,10.779" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M480.426,350.404l-53.241,30.739" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M399.417,397.174l-32.899,18.995" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M274.838,258.035l-58.6,33.833" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M188.364,307.961l-30.692,17.72" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M130.019,341.646l-15.169,8.758" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M480.426,403.17l-9.502,5.486" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M445.226,423.493c-5.972,3.477 3.159,-1.57 -19.794,11.428" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M397.558,451.014l-8.24,4.758" style="fill:none;stroke:#ed5152;stroke-width:7px;"/></g></svg> 2 | -------------------------------------------------------------------------------- /www/static/img/shyft-logo.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 596 596" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><path d="M297.638,9.317l249.693,144.16l0,288.321l-249.693,144.16l-249.693,-144.16l0,-288.321l249.693,-144.16Z" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M297.638,47.945l216.24,124.846l0,249.693l-216.24,124.847l-216.241,-124.847l0,-249.693l216.241,-124.846Z" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><g><path d="M480.426,192.105l-365.576,211.065" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M206.198,139.365l-18.456,10.656" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M160.49,165.755l-45.64,26.35" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M480.426,244.871l-84.248,48.641" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M368.303,309.605l-47.385,27.358" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M228.998,178.968l-45.554,26.301" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M155.57,221.362l-40.72,23.509" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M480.426,297.638l-9.879,5.704" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M442.592,319.435l-60.141,34.429" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M355.166,369.957l-11.688,6.748" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M252.038,218.432l-15.138,8.74" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M209.026,243.265l-51.133,29.522" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M133.519,286.859l-18.669,10.779" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M480.426,350.404l-53.241,30.739" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M399.417,397.174l-32.899,18.995" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M274.838,258.035l-58.6,33.833" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M188.364,307.961l-30.692,17.72" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M130.019,341.646l-15.169,8.758" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M480.426,403.17l-9.502,5.486" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M445.226,423.493c-5.972,3.477 3.159,-1.57 -19.794,11.428" style="fill:none;stroke:#ed5152;stroke-width:7px;"/><path d="M397.558,451.014l-8.24,4.758" style="fill:none;stroke:#ed5152;stroke-width:7px;"/></g></svg> 2 | -------------------------------------------------------------------------------- /src/graphqlProtocol/sort.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType, GraphQLList } from 'graphql'; 2 | import * as _ from 'lodash'; 3 | import { ProtocolGraphQL } from './ProtocolGraphQL'; 4 | import { ProtocolGraphQLConfiguration } from './ProtocolGraphQLConfiguration'; 5 | import { isEntity } from '../engine/entity/Entity'; 6 | import { isShadowEntity } from '../engine/entity/ShadowEntity'; 7 | import { getRegisteredEntityAttribute } from './registry'; 8 | 9 | export const generateSortInput = (entity) => { 10 | const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; 11 | const storageType = entity.storageType; 12 | 13 | const sortNames = {}; 14 | let defaultSortValue; 15 | 16 | _.forEach(entity.getAttributes(), (attribute) => { 17 | if (attribute.hidden || attribute.mutationInput) { 18 | return; 19 | } 20 | 21 | const { fieldName: gqlFieldName } = getRegisteredEntityAttribute( 22 | entity.name, 23 | attribute.name, 24 | ); 25 | 26 | let storageDataType; 27 | 28 | if (isEntity(attribute.type) || isShadowEntity(attribute.type)) { 29 | const targetPrimaryAttribute = attribute.type.getPrimaryAttribute(); 30 | storageDataType = storageType.convertToStorageDataType( 31 | targetPrimaryAttribute.type, 32 | ); 33 | } else { 34 | storageDataType = storageType.convertToStorageDataType(attribute.type); 35 | } 36 | 37 | if (!storageDataType.isSortable) { 38 | return; 39 | } 40 | 41 | const keyAsc = protocolConfiguration.generateSortKeyName(attribute, true); 42 | const keyDesc = protocolConfiguration.generateSortKeyName(attribute, false); 43 | 44 | // add ascending key 45 | sortNames[keyAsc] = { 46 | description: `Order by **\`${gqlFieldName}\`** ascending`, 47 | value: { 48 | attribute: attribute.name, 49 | direction: 'ASC', 50 | }, 51 | }; 52 | 53 | if (attribute.primary) { 54 | defaultSortValue = sortNames[keyAsc].value; 55 | } 56 | 57 | // add descending key 58 | sortNames[keyDesc] = { 59 | description: `Order by **\`${gqlFieldName}\`** descending`, 60 | value: { 61 | attribute: attribute.name, 62 | direction: 'DESC', 63 | }, 64 | }; 65 | }); 66 | 67 | if (_.isEmpty(sortNames)) { 68 | return null; 69 | } 70 | 71 | const sortInputType = new GraphQLEnumType({ 72 | name: protocolConfiguration.generateSortInputTypeName(entity), 73 | values: sortNames, 74 | }); 75 | 76 | if (!defaultSortValue) { 77 | defaultSortValue = sortInputType.getValues()[0].value; 78 | } 79 | 80 | return { 81 | type: new GraphQLList(sortInputType), 82 | description: 'Order list by a single or multiple attributes', 83 | defaultValue: [defaultSortValue], 84 | }; 85 | }; 86 | -------------------------------------------------------------------------------- /test/models/Profile.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | DataTypeString, 4 | DataTypeTimestampTz, 5 | Index, 6 | INDEX_UNIQUE, 7 | Mutation, 8 | MUTATION_TYPE_CREATE, 9 | Permission, 10 | } from '../../src'; 11 | 12 | import crypto from 'crypto'; 13 | 14 | const readPermissions = [ 15 | new Permission().role('admin'), 16 | // new Permission() 17 | // .userAttribute('id'), 18 | ]; 19 | 20 | export const Profile = new Entity({ 21 | name: 'Profile', 22 | description: 'A user profile', 23 | 24 | isUserEntity: true, 25 | 26 | includeUserTracking: true, 27 | includeTimeTracking: true, 28 | 29 | indexes: [ 30 | new Index({ 31 | type: INDEX_UNIQUE, 32 | attributes: ['username'], 33 | }), 34 | ], 35 | 36 | mutations: [ 37 | new Mutation({ 38 | name: 'signup', 39 | description: 'sign up a new user', 40 | type: MUTATION_TYPE_CREATE, 41 | attributes: ['username', 'password', 'firstname', 'lastname'], 42 | preProcessor({ input }) { 43 | /** 44 | * ATTENTION! 45 | * DO NOT COPY this very unsecure method of password hashing! 46 | * This is ONLY for testing purposes! 47 | */ 48 | return { 49 | ...input, 50 | username: (<string>input.username).toLowerCase(), 51 | password: crypto 52 | .createHash('sha256') 53 | .update(<string>input.password, 'utf8') 54 | .digest('hex'), 55 | }; 56 | }, 57 | }), 58 | ], 59 | 60 | permissions: { 61 | read: Permission.AUTHENTICATED, 62 | find: readPermissions, 63 | mutations: { 64 | signup: Permission.EVERYONE, 65 | }, 66 | }, 67 | 68 | attributes: { 69 | username: { 70 | type: DataTypeString, 71 | description: 'Username', 72 | required: true, 73 | }, 74 | 75 | password: { 76 | type: DataTypeString, 77 | description: 'The password of the user', 78 | required: true, 79 | hidden: true, 80 | }, 81 | 82 | firstname: { 83 | type: DataTypeString, 84 | description: 'First name', 85 | required: true, 86 | }, 87 | 88 | lastname: { 89 | type: DataTypeString, 90 | description: 'Last name', 91 | required: true, 92 | }, 93 | 94 | registeredAt: { 95 | type: DataTypeTimestampTz, 96 | description: 'Time of user registration', 97 | required: true, 98 | defaultValue() { 99 | return new Date(); 100 | }, 101 | }, 102 | 103 | confirmedAt: { 104 | type: DataTypeTimestampTz, 105 | description: 'Time of confirmation by the user', 106 | defaultValue() { 107 | return new Date(); 108 | }, 109 | }, 110 | }, 111 | }); 112 | -------------------------------------------------------------------------------- /src/engine/datatype/DataType.ts: -------------------------------------------------------------------------------- 1 | import { Source } from 'graphql'; 2 | import { Entity, ShadowEntity, ViewEntity } from '../..'; 3 | import { Context } from '../context/Context'; 4 | import { passOrThrow, isFunction } from '../util'; 5 | import { ComplexDataType } from './ComplexDataType'; 6 | 7 | export type DataTypeValidateType = (params: { 8 | value?: unknown; 9 | source?: Source; 10 | context?: Context; 11 | }) => void | Promise<void>; 12 | 13 | export type DataTypeSetup = { 14 | name?: string; 15 | description?: string; 16 | mock?: () => any; 17 | validate?: DataTypeValidateType; 18 | enforceRequired?: boolean; 19 | defaultValue?: () => any; 20 | enforceIndex?: boolean; 21 | }; 22 | 23 | export type DataTypeFunction = (params: { 24 | setup: DataTypeSetup; 25 | entity?: Entity | ShadowEntity | ViewEntity; 26 | }) => DataType | ComplexDataType; 27 | 28 | export class DataType { 29 | name: string; 30 | description: string; 31 | mock?: () => any; 32 | validate?: DataTypeValidateType; 33 | enforceRequired?: boolean; 34 | defaultValue?: () => any; 35 | enforceIndex?: boolean; 36 | 37 | constructor(setup: DataTypeSetup = {} as DataTypeSetup) { 38 | const { 39 | name, 40 | description, 41 | mock, 42 | validate, 43 | enforceRequired, 44 | defaultValue, 45 | enforceIndex, 46 | } = setup; 47 | 48 | passOrThrow(name, () => 'Missing data type name'); 49 | passOrThrow( 50 | description, 51 | () => `Missing description for data type '${name}'`, 52 | ); 53 | 54 | passOrThrow( 55 | isFunction(mock), 56 | () => `'Missing mock function for data type '${name}'`, 57 | ); 58 | 59 | if (validate) { 60 | passOrThrow( 61 | isFunction(validate), 62 | () => `'Invalid validate function for data type '${name}'`, 63 | ); 64 | 65 | this.validate = async (params) => { 66 | if (params?.value) { 67 | return await validate(params); 68 | } 69 | }; 70 | } 71 | 72 | if (defaultValue) { 73 | passOrThrow( 74 | isFunction(defaultValue), 75 | () => `'Invalid defaultValue function for data type '${name}'`, 76 | ); 77 | 78 | this.defaultValue = defaultValue; 79 | } 80 | 81 | this.name = name; 82 | this.description = description; 83 | this.mock = mock; 84 | 85 | if (enforceRequired) { 86 | this.enforceRequired = enforceRequired; 87 | } 88 | 89 | if (enforceIndex) { 90 | this.enforceIndex = enforceIndex; 91 | } 92 | } 93 | 94 | toString(): string { 95 | return this.name; 96 | } 97 | } 98 | 99 | export const isDataType = (obj: unknown): obj is DataType => { 100 | return obj instanceof DataType; 101 | }; 102 | -------------------------------------------------------------------------------- /test/data/invites.csv: -------------------------------------------------------------------------------- 1 | Cumque impedit est,55,101,0 2 | Veritatis nihil cum,52,7,1 3 | Non iusto unde,30,23,0 4 | Vel ullam,72,39,1 5 | Nobis totam,41,107,0 6 | Suscipit quas,47,41,0 7 | Occaecati adipisci vel,79,61,0 8 | Veritatis nihil cum,52,58,1 9 | Excepturi amet animi,88,3,1 10 | Veritatis nihil cum,52,104,0 11 | Sed necessitatibus facilis,85,83,0 12 | Ut et,42,95,1 13 | Sed necessitatibus facilis,85,82,1 14 | Et eum,46,92,1 15 | Suscipit quas,47,40,1 16 | Sed assumenda repellendus,84,51,0 17 | Excepturi amet animi,88,107,0 18 | Nobis totam,41,81,0 19 | Ut et,42,15,1 20 | Aliquam voluptates,78,21,1 21 | Sapiente laborum non,61,73,1 22 | Cumque impedit est,55,78,1 23 | Occaecati adipisci vel,79,1,1 24 | Nobis totam,41,21,1 25 | Ut et,42,67,0 26 | Sed necessitatibus facilis,85,10,1 27 | Non iusto unde,30,31,0 28 | Vel ullam,72,7,0 29 | Excepturi amet animi,88,34,0 30 | Veritatis nihil cum,52,85,0 31 | Sit aut,71,26,1 32 | Expedita qui,61,38,1 33 | Non iusto unde,30,40,1 34 | Nobis totam,41,103,1 35 | Excepturi amet animi,88,89,0 36 | Quo ut rerum,37,33,1 37 | Sit aut,71,59,0 38 | Vel ullam,72,44,0 39 | Sed assumenda repellendus,84,4,0 40 | Expedita qui,61,32,0 41 | Aliquam voluptates,78,68,1 42 | Aliquam voluptates,78,61,1 43 | Illo sit quia,59,5,0 44 | Et architecto,63,109,0 45 | Veritatis nihil cum,52,57,0 46 | Illo sit quia,59,64,1 47 | Aliquam voluptates,78,107,1 48 | Quo ut rerum,37,71,0 49 | Vel ullam,72,89,0 50 | Sit aut,71,53,1 51 | Sapiente laborum non,61,74,1 52 | Quo ut rerum,37,35,0 53 | Odit qui,26,70,0 54 | Sit aut,71,76,0 55 | Illo sit quia,59,68,1 56 | Sapiente laborum non,61,55,1 57 | Quo ut rerum,37,91,0 58 | Quo ut rerum,37,106,1 59 | Vel ullam,72,71,1 60 | Vel ullam,72,16,1 61 | Aliquam voluptates,78,51,0 62 | Occaecati adipisci vel,79,38,0 63 | Odit qui,26,8,1 64 | Occaecati adipisci vel,79,24,0 65 | Aliquam voluptates,78,36,1 66 | Odit qui,26,46,0 67 | Quo ut rerum,37,23,0 68 | Non iusto unde,30,89,1 69 | Sed assumenda repellendus,84,14,0 70 | Illo sit quia,59,70,0 71 | Veritatis nihil cum,52,46,0 72 | Sapiente laborum non,61,107,0 73 | Vel ullam,72,84,1 74 | Illo sit quia,59,30,0 75 | Sapiente laborum non,61,99,1 76 | Non iusto unde,30,37,0 77 | Vel ullam,72,64,1 78 | Sed necessitatibus facilis,85,50,1 79 | Ut et,42,86,0 80 | Cumque impedit est,55,38,1 81 | Et eum,46,40,0 82 | Odit qui,26,100,1 83 | Sit aut,71,34,1 84 | Expedita qui,61,15,0 85 | Suscipit quas,47,8,0 86 | Excepturi amet animi,88,20,0 87 | Illo sit quia,59,14,1 88 | Excepturi amet animi,88,26,0 89 | Non iusto unde,30,51,1 90 | Suscipit quas,47,59,0 91 | Veritatis nihil cum,52,35,0 92 | Et architecto,63,32,1 93 | Sed necessitatibus facilis,85,99,0 94 | Cumque impedit est,55,16,1 95 | Et architecto,63,45,1 96 | Quo ut rerum,37,25,0 97 | Et architecto,63,40,1 98 | Non iusto unde,30,17,1 99 | Sed necessitatibus facilis,85,27,0 100 | Sed assumenda repellendus,84,90,0 101 | -------------------------------------------------------------------------------- /www/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: #ee5152; 11 | --ifm-color-secondary: #3E3452; 12 | --ifm-heading-color: #24292e; 13 | --ifm-font-family-base: Roboto,Helvetica Neue,Helvetica,"sans-serif"; 14 | --ifm-code-font-size: 95%; 15 | --ifm-navbar-background-color: var(--ifm-color-primary); 16 | --ifm-footer-link-color: #ffffff; 17 | --ifm-footer-color: #ffffff; 18 | } 19 | 20 | body { 21 | font-family: var(--ifm-font-family-base); 22 | font-weight: 400; 23 | } 24 | 25 | .navbar { 26 | box-shadow: none; 27 | padding-left: 10%; 28 | padding-right: 10%; 29 | } 30 | 31 | .navbar__title,.navbar__link { 32 | color: hsla(0,0%,100%,.8); 33 | transition: color var(--ifm-transition-fast) var(--ifm-transition-timing-default); 34 | } 35 | 36 | .navbar__link:hover { 37 | color: white; 38 | } 39 | 40 | .hero { 41 | display: inherit; 42 | padding: 1rem; 43 | } 44 | 45 | .hero h1 { 46 | margin-top: 20px; 47 | margin-bottom: 50px; 48 | font-size: 70px; 49 | font-weight: 100; 50 | color: #ffffff !important; 51 | } 52 | 53 | .hero h2 { 54 | font-weight: 200; 55 | font-size: 24px; 56 | color: #ffffff !important; 57 | } 58 | 59 | .intro { 60 | text-align: center; 61 | } 62 | 63 | .intro p { 64 | font-size: 18px; 65 | } 66 | 67 | .intro h2 { 68 | color: #3e3452; 69 | font-family: var(--ifm-font-family-base); 70 | font-weight: 400; 71 | font-size: 26px; 72 | } 73 | 74 | .feature-grid span { 75 | color: #3e3452; 76 | font-family: Roboto,Helvetica Neue,Helvetica,"sans-serif"; 77 | font-weight: 400; 78 | font-size: 24px; 79 | border-bottom: 2px solid #ee5152; 80 | } 81 | 82 | .docusaurus-highlight-code-line { 83 | background-color: rgb(72, 77, 91); 84 | display: block; 85 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 86 | padding: 0 var(--ifm-pre-padding); 87 | } 88 | 89 | .row.footer__links { 90 | text-align: center; 91 | } 92 | 93 | .footer__link-item:hover { 94 | text-decoration: none; 95 | } 96 | 97 | .footer,.footer--dark { 98 | background: var(--ifm-color-secondary); 99 | --ifm-footer-color: #ffffff; 100 | --ifm-footer-link-color: #ffffff; 101 | } 102 | 103 | @media only screen and (min-width: 1024px) { 104 | 105 | .hero .logo { 106 | height: 300px !important; 107 | } 108 | 109 | .hero .title { 110 | font-size: 110px; 111 | } 112 | 113 | .hero h2 { 114 | font-size: 30px; 115 | } 116 | 117 | .featureSection h2 { 118 | font-size: 30px; 119 | } 120 | 121 | .featureSection .intro p { 122 | font-size: 22px; 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /www/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'Shyft · A server-side framework for building powerful GraphQL APIs', 3 | url: 'https://www.shyft.dev', 4 | baseUrl: '/', 5 | onBrokenLinks: 'throw', 6 | onBrokenMarkdownLinks: 'warn', 7 | favicon: 'img/favicon/favicon.ico', 8 | organizationName: 'Chris Kalmar', // Usually your GitHub org/user name. 9 | projectName: 'shyft', // Usually your repo name. 10 | themeConfig: { 11 | navbar: { 12 | title: 'Shyft', 13 | logo: { 14 | alt: 'My Site Logo', 15 | src: 'img/shyft-logo-white.svg', 16 | }, 17 | items: [ 18 | { 19 | label: 'Intro', 20 | to: '/intro', 21 | }, 22 | { 23 | to: 'docs/api', 24 | label: 'API', 25 | }, 26 | ], 27 | }, 28 | footer: { 29 | style: 'dark', 30 | links: [ 31 | { 32 | items: [ 33 | { 34 | label: 'API', 35 | to: '/docs/api', 36 | }, 37 | { 38 | label: 'Intro', 39 | to: '/guides', 40 | }, 41 | ], 42 | }, 43 | { 44 | items: [ 45 | { 46 | label: 'GitHub', 47 | href: 'https://github.com/chriskalmar/shyft', 48 | }, 49 | { 50 | html: ` 51 | <a 52 | class="github-button" 53 | href="https://github.com/chriskalmar/shyft" 54 | data-color-scheme="no-preference: light; light: light; dark: dark;" 55 | data-icon="octicon-star" 56 | data-size="large" 57 | data-show-count="true" 58 | aria-label="Star chriskalmar/shyft on GitHub" 59 | > 60 | Star 61 | </a> 62 | `, 63 | }, 64 | ], 65 | }, 66 | ], 67 | copyright: `Copyright © ${new Date().getFullYear()} Chris Kalmar. Built with Docusaurus.`, 68 | }, 69 | }, 70 | presets: [ 71 | [ 72 | '@docusaurus/preset-classic', 73 | { 74 | docs: { 75 | sidebarPath: require.resolve('./sidebars.js'), 76 | // Please change this to your repo. 77 | editUrl: 'https://github.com/chriskalmar/shyft', 78 | }, 79 | theme: { 80 | customCss: require.resolve('./src/css/custom.css'), 81 | }, 82 | }, 83 | ], 84 | ], 85 | plugins: [ 86 | [ 87 | 'docusaurus-plugin-typedoc', 88 | // Plugin / TypeDoc options 89 | { 90 | entryPoints: ['../src/index.ts'], 91 | tsconfig: '../tsconfig.json', 92 | readme: 'none', 93 | exclude: '**/*+(index|.spec|.e2e).ts', 94 | excludePrivate: true, 95 | excludeExternals: true, 96 | includeVersion: true, 97 | }, 98 | ], 99 | ], 100 | }; 101 | -------------------------------------------------------------------------------- /src/storage-connector/helpers.ts: -------------------------------------------------------------------------------- 1 | import StorageTypePostgres from './StorageTypePostgres'; 2 | import { isEntity, isShadowEntity } from '..'; 3 | import * as _ from 'lodash'; 4 | 5 | export const parseValues = (entity, data, model, context) => { 6 | if (!data) { 7 | return data; 8 | } 9 | 10 | const entityAttributes = entity.getAttributes(); 11 | 12 | _.forEach(entityAttributes, (attribute) => { 13 | const attributeName = attribute.name; 14 | const value = data[attributeName]; 15 | 16 | if (typeof value === 'undefined' || attribute.mutationInput) { 17 | return; 18 | } 19 | 20 | let attributeType; 21 | 22 | if (isEntity(attribute.type) || isShadowEntity(attribute.type)) { 23 | const primaryAttribute = attribute.type.getPrimaryAttribute(); 24 | attributeType = primaryAttribute.type; 25 | } else { 26 | attributeType = attribute.type; 27 | } 28 | 29 | const storageDataType = StorageTypePostgres.convertToStorageDataType( 30 | attributeType, 31 | ); 32 | 33 | if (storageDataType.parse) { 34 | data[attributeName] = storageDataType.parse(value, { 35 | data, 36 | entity, 37 | model, 38 | context, 39 | }); 40 | } 41 | }); 42 | 43 | return data; 44 | }; 45 | 46 | export const parseValuesMap = (entity, rows, model, context) => { 47 | return rows.map((row) => parseValues(entity, row, model, context)); 48 | }; 49 | 50 | export const serializeValues = (entity, mutation, data, model, context) => { 51 | const entityAttributes = entity.getAttributes(); 52 | const mutationAttributes = mutation.attributes || []; 53 | 54 | _.forEach(entityAttributes, (attribute) => { 55 | if (attribute.mutationInput) { 56 | return; 57 | } 58 | 59 | const attributeName = attribute.name; 60 | const value = data[attributeName]; 61 | 62 | let attributeType; 63 | 64 | if (isEntity(attribute.type) || isShadowEntity(attribute.type)) { 65 | const primaryAttribute = attribute.type.getPrimaryAttribute(); 66 | attributeType = primaryAttribute.type; 67 | } else { 68 | attributeType = attribute.type; 69 | } 70 | 71 | const storageDataType = StorageTypePostgres.convertToStorageDataType( 72 | attributeType, 73 | ); 74 | 75 | if ( 76 | !mutationAttributes.includes(attributeName) && 77 | !storageDataType.enforceSerialize 78 | ) { 79 | return; 80 | } 81 | 82 | if (storageDataType.serialize) { 83 | data[attributeName] = storageDataType.serialize(value, { 84 | data, 85 | entity, 86 | mutation, 87 | model, 88 | context, 89 | }); 90 | } 91 | }); 92 | 93 | return data; 94 | }; 95 | 96 | export const runTestPlaceholderQuery = async (cmd, vars) => { 97 | const storageInstance = StorageTypePostgres.getStorageInstance(); 98 | const manager = storageInstance.manager; 99 | 100 | const [query, parameters] = storageInstance.driver.escapeQueryWithParameters( 101 | cmd, 102 | vars, 103 | {}, 104 | ); 105 | 106 | return manager.query(query, parameters); 107 | }; 108 | -------------------------------------------------------------------------------- /src/engine/constants.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | 3 | export const ATTRIBUTE_NAME_PATTERN = '^[a-zA-Z][a-zA-Z0-9_]*$'; 4 | export const attributeNameRegex = new RegExp(ATTRIBUTE_NAME_PATTERN); 5 | 6 | export const ENUM_VALUE_PATTERN = '^[_a-zA-Z][_a-zA-Z0-9]*$'; 7 | export const enumValueRegex = new RegExp(ENUM_VALUE_PATTERN); 8 | 9 | export const STATE_NAME_PATTERN = '^[a-zA-Z][_a-zA-Z0-9]*$'; 10 | export const stateNameRegex = new RegExp(STATE_NAME_PATTERN); 11 | 12 | export const LANGUAGE_ISO_CODE_PATTERN = '^[a-z]+$'; 13 | export const languageIsoCodeRegex = new RegExp(LANGUAGE_ISO_CODE_PATTERN); 14 | 15 | export const storageDataTypeCapabilityType = { 16 | VALUE: 1, 17 | LIST: 2, 18 | STRING: 3, 19 | BOOLEAN: 4 20 | }; 21 | 22 | const sdtcVALUE = storageDataTypeCapabilityType.VALUE; 23 | const sdtcLIST = storageDataTypeCapabilityType.LIST; 24 | const sdtcSTRING = storageDataTypeCapabilityType.STRING; 25 | const sdtcBOOLEAN = storageDataTypeCapabilityType.BOOLEAN; 26 | 27 | export const storageDataTypeCapabilities = { 28 | in: sdtcLIST, 29 | lt: sdtcVALUE, 30 | lte: sdtcVALUE, 31 | gt: sdtcVALUE, 32 | gte: sdtcVALUE, 33 | contains: sdtcVALUE, 34 | starts_with: sdtcVALUE, 35 | ends_with: sdtcVALUE, 36 | ne: sdtcVALUE, 37 | not_in: sdtcLIST, 38 | not_contains: sdtcVALUE, 39 | not_starts_with: sdtcVALUE, 40 | not_ends_with: sdtcVALUE, 41 | includes: sdtcSTRING, 42 | not_includes: sdtcSTRING, 43 | is_null: sdtcBOOLEAN, 44 | }; 45 | 46 | export const entityPropertiesWhitelist: Array<string> = [ 47 | 'name', 48 | 'description', 49 | 'attributes', 50 | 'storageType', 51 | 'isUserEntity', 52 | 'includeTimeTracking', 53 | 'includeUserTracking', 54 | 'indexes', 55 | 'mutations', 56 | 'subscriptions', 57 | 'permissions', 58 | 'states', 59 | 'preProcessor', 60 | 'postProcessor', 61 | 'preFilters', 62 | 'meta', 63 | ]; 64 | 65 | export const attributePropertiesWhitelist: Array<string> = [ 66 | 'name', 67 | 'description', 68 | 'type', 69 | 'required', 70 | 'primary', 71 | 'unique', 72 | 'index', 73 | 'resolve', 74 | 'defaultValue', 75 | 'serialize', 76 | 'validate', 77 | 'hidden', 78 | 'i18n', 79 | 'mock', 80 | 'mutationInput', 81 | 'meta', 82 | ]; 83 | 84 | export const viewEntityPropertiesWhitelist: Array<string> = [ 85 | 'name', 86 | 'description', 87 | 'attributes', 88 | 'storageType', 89 | 'viewExpression', 90 | 'permissions', 91 | 'preProcessor', 92 | 'postProcessor', 93 | 'preFilters', 94 | 'meta', 95 | ]; 96 | 97 | export const viewAttributePropertiesWhitelist: Array<string> = [ 98 | 'name', 99 | 'description', 100 | 'type', 101 | 'required', 102 | 'primary', 103 | 'resolve', 104 | 'mock', 105 | 'meta', 106 | ]; 107 | 108 | export const shadowEntityPropertiesWhitelist: Array<string> = [ 109 | 'name', 110 | 'attributes', 111 | 'storageType', 112 | 'isUserEntity', 113 | 'meta', 114 | ]; 115 | 116 | export const shadowEntityAttributePropertiesWhitelist: Array<string> = [ 117 | 'name', 118 | 'description', 119 | 'type', 120 | 'required', 121 | 'primary', 122 | 'meta', 123 | ]; 124 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shyft", 3 | "version": "1.3.0", 4 | "description": "Model driven GraphQL API framework", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "build-watch": "tsc -w", 10 | "clean": "rimraf lib", 11 | "coverage": "cross-env NODE_ENV=test TZ=UTC jest --runInBand --coverage", 12 | "coverage-ci": "npm run coverage && cat ./coverage/lcov.info | codecov", 13 | "lint": "tsc --noEmit && eslint '*/**/*.{js,ts,tsx}'", 14 | "lint-fix": "tsc --noEmit && eslint '*/**/*.{js,ts,tsx}' --fix", 15 | "lint-staged": "lint-staged", 16 | "prepare": "husky install", 17 | "release": "np --no-publish", 18 | "test": "jest --testPathIgnorePatterns test/", 19 | "test-integration": "TZ=UTC jest --runInBand", 20 | "test-integration-watch": "TZ=UTC jest --runInBand --watch", 21 | "test-watch": "jest --testPathIgnorePatterns test/ --watch" 22 | }, 23 | "files": [ 24 | "lib", 25 | "storageScripts" 26 | ], 27 | "jest": { 28 | "testEnvironment": "node" 29 | }, 30 | "lint-staged": { 31 | "*.{js,ts}": [ 32 | "./node_modules/eslint/bin/eslint.js" 33 | ] 34 | }, 35 | "husky": { 36 | "hooks": { 37 | "pre-commit": "lint-staged" 38 | } 39 | }, 40 | "keywords": [ 41 | "shyft", 42 | "graphql", 43 | "api", 44 | "model", 45 | "generator", 46 | "database", 47 | "workflow", 48 | "permissions", 49 | "validation", 50 | "workflows", 51 | "finite-state-machine", 52 | "acl", 53 | "migrations", 54 | "shyft", 55 | "graphql-apis", 56 | "mutations" 57 | ], 58 | "author": "Chris Kalmar <christian.kalmar@gmail.com>", 59 | "license": "MIT", 60 | "repository": { 61 | "type": "git", 62 | "url": "https://github.com/chriskalmar/shyft" 63 | }, 64 | "devDependencies": { 65 | "@types/graphql": "14.5.0", 66 | "@types/jest": "26.0.23", 67 | "@types/lodash": "4.14.170", 68 | "@types/node": "14.17.3", 69 | "@typescript-eslint/eslint-plugin": "4.26.1", 70 | "@typescript-eslint/parser": "4.26.1", 71 | "codecov": "3.8.2", 72 | "cross-env": "7.0.3", 73 | "eslint": "7.28.0", 74 | "eslint-config-prettier": "8.3.0", 75 | "eslint-import-resolver-typescript": "2.4.0", 76 | "eslint-plugin-import": "2.23.4", 77 | "eslint-plugin-prettier": "3.4.0", 78 | "husky": "6.0.0", 79 | "jest": "26.6.3", 80 | "lint-staged": "11.0.0", 81 | "pg": "8.6.0", 82 | "prettier": "2.3.1", 83 | "prettier-eslint": "12.0.0", 84 | "prettier-eslint-cli": "5.0.1", 85 | "rimraf": "3.0.2", 86 | "sqlite3": "5.0.2", 87 | "ts-jest": "26.5.6", 88 | "typeorm": "0.2.34", 89 | "typescript": "4.2.4" 90 | }, 91 | "dependencies": { 92 | "casual": "^1.6.2", 93 | "dataloader": "^2.0.0", 94 | "date-fns": "^2.1.0", 95 | "graphql": "^15.5.0", 96 | "graphql-relay": "^0.6.0", 97 | "graphql-subscriptions": "^1.1.0", 98 | "graphql-type-json": "^0.3.0", 99 | "json-shaper": "^1.2.0", 100 | "lodash": "4.17.21", 101 | "pluralize": "^8.0.0", 102 | "toposort": "^2.0.2" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /test/models/Board.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | DataTypeString, 4 | DataTypeBoolean, 5 | Mutation, 6 | MUTATION_TYPE_CREATE, 7 | MUTATION_TYPE_UPDATE, 8 | Permission, 9 | Index, 10 | INDEX_UNIQUE, 11 | INDEX_GENERIC, 12 | buildObjectDataType, 13 | buildListDataType, 14 | DataTypeJson, 15 | } from '../../src'; 16 | 17 | import { Profile } from './Profile'; 18 | 19 | const readPermissions = [ 20 | new Permission().role('admin'), 21 | new Permission().value('isPrivate', false), 22 | new Permission().userAttribute('owner'), 23 | // eslint-disable-next-line @typescript-eslint/no-var-requires 24 | new Permission().lookup(() => require('./BoardMember').BoardMember, { 25 | board: 'id', 26 | invitee: ({ userId }) => userId, 27 | state: () => ['invited', 'accepted'], 28 | }), 29 | ]; 30 | 31 | export const Board = new Entity({ 32 | name: 'Board', 33 | description: 'A chat board', 34 | 35 | includeUserTracking: true, 36 | includeTimeTracking: true, 37 | 38 | indexes: [ 39 | new Index({ 40 | type: INDEX_UNIQUE, 41 | attributes: ['name'], 42 | }), 43 | new Index({ 44 | type: INDEX_GENERIC, 45 | attributes: ['isPrivate'], 46 | }), 47 | new Index({ 48 | type: INDEX_GENERIC, 49 | attributes: ['owner'], 50 | }), 51 | new Index({ 52 | type: INDEX_UNIQUE, 53 | attributes: ['vip'], 54 | }), 55 | ], 56 | 57 | mutations: ({ createMutation }) => [ 58 | createMutation, 59 | new Mutation({ 60 | name: 'build', 61 | description: 'build a new board', 62 | type: MUTATION_TYPE_CREATE, 63 | attributes: ['name', 'isPrivate', 'vip', 'metaData', 'mods'], 64 | }), 65 | new Mutation({ 66 | name: 'update', 67 | description: 'update a board', 68 | type: MUTATION_TYPE_UPDATE, 69 | attributes: ['name', 'isPrivate', 'vip'], 70 | }), 71 | ], 72 | 73 | permissions: { 74 | read: readPermissions, 75 | find: readPermissions, 76 | mutations: { 77 | build: Permission.AUTHENTICATED, 78 | }, 79 | }, 80 | 81 | attributes: { 82 | name: { 83 | type: DataTypeString, 84 | description: 'Name of the board', 85 | required: true, 86 | }, 87 | 88 | owner: { 89 | type: Profile, 90 | description: 'Owner of the board', 91 | required: true, 92 | defaultValue({ context: { userId } }) { 93 | return userId; 94 | }, 95 | }, 96 | 97 | vip: { 98 | type: Profile, 99 | description: 'VIP guest of the board', 100 | required: true, 101 | }, 102 | 103 | isPrivate: { 104 | type: DataTypeBoolean, 105 | description: 'It is a private board', 106 | }, 107 | 108 | metaData: { 109 | type: buildObjectDataType({ 110 | attributes: { 111 | description: { 112 | type: DataTypeString, 113 | description: 'Board description', 114 | }, 115 | externalLinks: { 116 | type: DataTypeJson, 117 | description: 'External links', 118 | }, 119 | }, 120 | }), 121 | description: 'Meta data', 122 | }, 123 | 124 | mods: { 125 | type: buildListDataType({ 126 | itemType: DataTypeString, 127 | }), 128 | description: 'List of moderators', 129 | }, 130 | }, 131 | }); 132 | -------------------------------------------------------------------------------- /test/testSetGenerator.ts: -------------------------------------------------------------------------------- 1 | import casual from 'casual'; 2 | import * as _ from 'lodash'; 3 | import { generateRows, readRows, writeTestDataFile } from './testingData'; 4 | 5 | const mockProfiles = (profileCount) => { 6 | generateRows(profileCount, 'profiles', () => { 7 | const firstName = casual.first_name; 8 | return [ 9 | firstName.toLowerCase() + casual.integer(100, 999), 10 | casual.word, 11 | firstName, 12 | casual.last_name, 13 | ]; 14 | }); 15 | }; 16 | 17 | const mockBoards = (boardCount, profileCount) => { 18 | generateRows(boardCount, 'boards', () => { 19 | return [ 20 | casual.title, 21 | casual.integer(5, profileCount - 20), 22 | casual.integer(0, 1), 23 | ]; 24 | }); 25 | }; 26 | 27 | const mockInvites = (inviteCount, profileCount) => { 28 | const uniqnessCache = []; 29 | const boards = readRows('boards'); 30 | const invites = []; 31 | 32 | while (invites.length < inviteCount) { 33 | const [name, owner, isPrivate] = _.sample(boards); 34 | 35 | if (isPrivate === '1') { 36 | const invitee = casual.integer(1, profileCount); 37 | const accept = casual.integer(0, 1); 38 | 39 | if (invitee !== parseInt(owner, 10)) { 40 | const row = `${name},${owner},${invitee},${accept}`; 41 | 42 | if (!uniqnessCache.includes(row)) { 43 | uniqnessCache.push(row); 44 | invites.push(row); 45 | } 46 | } 47 | } 48 | } 49 | 50 | writeTestDataFile('invites', invites); 51 | }; 52 | 53 | const mockJoins = (joinCount, profileCount) => { 54 | const uniqnessCache = []; 55 | const boards = readRows('boards'); 56 | const joins = []; 57 | 58 | while (joins.length < joinCount) { 59 | const [name, owner, isPrivate] = _.sample(boards); 60 | 61 | if (isPrivate === '0') { 62 | const invitee = casual.integer(1, profileCount); 63 | 64 | if (invitee !== parseInt(owner, 10)) { 65 | const row = `${name},${invitee}`; 66 | 67 | if (!uniqnessCache.includes(row)) { 68 | uniqnessCache.push(row); 69 | joins.push(row); 70 | } 71 | } 72 | } 73 | } 74 | 75 | writeTestDataFile('joins', joins); 76 | }; 77 | 78 | const emojiList = '😎 😀 🤘 😆 🙌 🍕 🍪 😂 😜 😊 😍 💩 ✅ 👌 🚀'.split(' '); 79 | 80 | const mockMessages = (messageCount, boardCount, profileCount) => { 81 | generateRows(messageCount, 'messages', () => { 82 | const now = new Date().getTime(); 83 | const randomFuture = now + casual.integer(0, 10000); 84 | const emoji = casual.coin_flip 85 | ? ` ${emojiList[casual.integer(0, emojiList.length - 1)]}` 86 | : ''; 87 | 88 | return [ 89 | casual.string + emoji, 90 | casual.integer(5, profileCount - 20), 91 | casual.integer(1, boardCount - 1), 92 | new Date(randomFuture).toISOString(), 93 | ]; 94 | }); 95 | }; 96 | 97 | const profileCount = 110; 98 | const boardCount = 50; 99 | const inviteCount = 100; 100 | const joinCount = 200; 101 | const messageCount = 50; 102 | 103 | export const generateMockData = () => { 104 | mockProfiles(profileCount); 105 | mockBoards(boardCount, profileCount); 106 | mockInvites(inviteCount, profileCount); 107 | mockJoins(joinCount, profileCount); 108 | mockMessages(messageCount, boardCount, profileCount); 109 | }; 110 | 111 | export const counts = { 112 | profileCount, 113 | boardCount, 114 | joinCount, 115 | inviteCount, 116 | messageCount, 117 | }; 118 | -------------------------------------------------------------------------------- /src/graphqlProtocol/dataTypes.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | 3 | import { GraphQLDateTime, GraphQLDate, GraphQLTime } from './dataTypes'; 4 | import { parseValue } from 'graphql'; 5 | 6 | describe('dataTypes', () => { 7 | it('GraphQLDateTime', () => { 8 | const bad1 = () => GraphQLDateTime.parseLiteral(parseValue('"foo"'), null); 9 | expect(bad1).toThrowErrorMatchingSnapshot(); 10 | 11 | const bad2 = () => 12 | GraphQLDateTime.parseLiteral( 13 | parseValue('"2000-01-02 111:12:13+14:15"'), 14 | null, 15 | ); 16 | expect(bad2).toThrowErrorMatchingSnapshot('bad hour'); 17 | 18 | const bad3 = () => 19 | GraphQLDateTime.parseLiteral( 20 | parseValue('"2000-31-02 11:12:13+14:15"'), 21 | null, 22 | ); 23 | expect(bad3).toThrowErrorMatchingSnapshot('bad month'); 24 | 25 | const bad4 = () => 26 | GraphQLDateTime.parseLiteral( 27 | parseValue('"2000-01-02 11:62:13+14:15"'), 28 | null, 29 | ); 30 | expect(bad4).toThrowErrorMatchingSnapshot('bad minute'); 31 | 32 | const good1 = () => 33 | GraphQLDateTime.parseLiteral( 34 | parseValue('"2000-01-02 11:12:13+14:15"'), 35 | null, 36 | ); 37 | expect(good1).not.toThrow(); 38 | 39 | const good2 = () => 40 | GraphQLDateTime.parseLiteral( 41 | parseValue('"0003-01-02 21:12:13+14:15"'), 42 | null, 43 | ); 44 | expect(good2).not.toThrow(); 45 | }); 46 | 47 | it('GraphQLDate', () => { 48 | const bad1 = () => GraphQLDate.parseLiteral(parseValue('"foo"'), null); 49 | expect(bad1).toThrowErrorMatchingSnapshot(); 50 | 51 | const bad2 = () => 52 | GraphQLDate.parseLiteral(parseValue('"2000-01-32"'), null); 53 | expect(bad2).toThrowErrorMatchingSnapshot('bad day'); 54 | 55 | const bad3 = () => 56 | GraphQLDate.parseLiteral(parseValue('"2000-31-02"'), null); 57 | expect(bad3).toThrowErrorMatchingSnapshot('bad month'); 58 | 59 | const bad4 = () => GraphQLDate.parseLiteral(parseValue('"01-02"'), null); 60 | expect(bad4).toThrowErrorMatchingSnapshot('bad date'); 61 | 62 | const good1 = () => 63 | GraphQLDate.parseLiteral(parseValue('"2000-01-02"'), null); 64 | expect(good1).not.toThrow(); 65 | 66 | const good2 = () => 67 | GraphQLDate.parseLiteral(parseValue('"0003-01-02"'), null); 68 | expect(good2).not.toThrow(); 69 | }); 70 | 71 | it('GraphQLTime', () => { 72 | const bad1 = () => GraphQLTime.parseLiteral(parseValue('"foo"'), null); 73 | expect(bad1).toThrowErrorMatchingSnapshot(); 74 | 75 | const bad2 = () => 76 | GraphQLTime.parseLiteral(parseValue('"111:12:13+14:15"'), null); 77 | expect(bad2).toThrowErrorMatchingSnapshot('bad hour'); 78 | 79 | const bad3 = () => 80 | GraphQLTime.parseLiteral(parseValue('"11:12:13+24:15"'), null); 81 | expect(bad3).toThrowErrorMatchingSnapshot('bad tz hour'); 82 | 83 | const bad4 = () => 84 | GraphQLTime.parseLiteral(parseValue('"11:62:13+14:15"'), null); 85 | expect(bad4).toThrowErrorMatchingSnapshot('bad minute'); 86 | 87 | const good1 = () => 88 | GraphQLTime.parseLiteral(parseValue('"11:12:13+14:15"'), null); 89 | expect(good1).not.toThrow(); 90 | 91 | const good2 = () => 92 | GraphQLTime.parseLiteral(parseValue('"21:12:13"'), null); 93 | expect(good2).not.toThrow(); 94 | 95 | const good3 = () => GraphQLTime.parseLiteral(parseValue('"21:12"'), null); 96 | expect(good3).not.toThrow(); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/engine/schema/Schema.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | 3 | import { Entity } from '../entity/Entity'; 4 | import { Schema } from './Schema'; 5 | 6 | import { DataTypeID, DataTypeString } from '../datatype/dataTypes'; 7 | 8 | describe('Schema', () => { 9 | const FirstEntity = new Entity({ 10 | name: 'FirstEntity', 11 | description: 'Just some description', 12 | attributes: { 13 | id: { 14 | type: DataTypeID, 15 | description: 'ID of item', 16 | }, 17 | name: { 18 | type: DataTypeString, 19 | description: 'Just another description', 20 | }, 21 | }, 22 | }); 23 | 24 | const SecondEntity = new Entity({ 25 | name: 'SecondEntity', 26 | description: 'Just some description', 27 | attributes: { 28 | id: { 29 | type: DataTypeID, 30 | description: 'ID of item', 31 | }, 32 | name: { 33 | type: DataTypeString, 34 | description: 'Just another description', 35 | }, 36 | first: { 37 | type: FirstEntity, 38 | description: 'Relationship with FirstEntity', 39 | }, 40 | }, 41 | }); 42 | 43 | it('should accept a list of (empty) entities', () => { 44 | // eslint-disable-next-line no-new 45 | new Schema({}); 46 | 47 | // eslint-disable-next-line no-new 48 | new Schema({ 49 | entities: [FirstEntity, SecondEntity], 50 | }); 51 | }); 52 | 53 | it('should reject invalid entities', () => { 54 | const schema = new Schema({}); 55 | 56 | function fn1() { 57 | schema.addEntity(undefined); 58 | } 59 | 60 | function fn2() { 61 | schema.addEntity({}); 62 | } 63 | 64 | function fn3() { 65 | schema.addEntity('so-wrong'); 66 | } 67 | 68 | expect(fn1).toThrowErrorMatchingSnapshot(); 69 | expect(fn2).toThrowErrorMatchingSnapshot(); 70 | expect(fn3).toThrowErrorMatchingSnapshot(); 71 | }); 72 | 73 | it('should accept new entities', () => { 74 | const schema = new Schema(); 75 | 76 | schema.addEntity(FirstEntity); 77 | schema.addEntity(SecondEntity); 78 | }); 79 | 80 | it('should reject an invalid entities list', () => { 81 | function fn1() { 82 | // eslint-disable-next-line no-new 83 | new Schema({ 84 | entities: [], 85 | }); 86 | } 87 | 88 | function fn2() { 89 | // eslint-disable-next-line no-new 90 | new Schema({ 91 | entities: ({} as unknown) as Entity[], 92 | }); 93 | } 94 | 95 | function fn3() { 96 | // eslint-disable-next-line no-new 97 | new Schema({ 98 | entities: ('so-wrong' as unknown) as Entity[], 99 | }); 100 | } 101 | 102 | function fn4() { 103 | // eslint-disable-next-line no-new 104 | new Schema({ 105 | entities: (['so-wrong'] as unknown) as Entity[], 106 | }); 107 | } 108 | 109 | expect(fn1).toThrowErrorMatchingSnapshot(); 110 | expect(fn2).toThrowErrorMatchingSnapshot(); 111 | expect(fn3).toThrowErrorMatchingSnapshot(); 112 | expect(fn4).toThrowErrorMatchingSnapshot(); 113 | }); 114 | 115 | it('should throw on duplicate entities', () => { 116 | const schema = new Schema({}); 117 | 118 | schema.addEntity(FirstEntity); 119 | schema.addEntity(SecondEntity); 120 | 121 | function fn() { 122 | schema.addEntity(FirstEntity); 123 | } 124 | 125 | expect(fn).toThrowErrorMatchingSnapshot(); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/engine/storage/StorageDataType.ts: -------------------------------------------------------------------------------- 1 | import { passOrThrow, isFunction, isArray } from '../util'; 2 | 3 | import { storageDataTypeCapabilities } from '../constants'; 4 | import { Entity } from '../entity/Entity'; 5 | import { Mutation } from '../mutation/Mutation'; 6 | import { Context } from '../context/Context'; 7 | import { RegistryEntity } from '../../graphqlProtocol/registry'; 8 | 9 | export type StorageDataTypeSerializer = { 10 | ( 11 | value: unknown, 12 | options?: { 13 | data?: Record<string, unknown>; 14 | entity?: Entity; 15 | mutation?: Mutation; 16 | model?: RegistryEntity; 17 | context?: Context; 18 | }, 19 | ): unknown; 20 | }; 21 | 22 | export type StorageDataTypeParser = { 23 | ( 24 | value: unknown, 25 | options?: { 26 | data?: Record<string, unknown>; 27 | entity?: Entity; 28 | model?: RegistryEntity; 29 | context?: Context; 30 | }, 31 | ): unknown; 32 | }; 33 | 34 | export type StorageDataTypeSetup = { 35 | name: string; 36 | description: string; 37 | nativeDataType: any; 38 | isSortable?: boolean; 39 | serialize?: StorageDataTypeSerializer; 40 | enforceSerialize?: boolean; 41 | parse?: StorageDataTypeParser; 42 | capabilities?: string[]; 43 | }; 44 | 45 | export class StorageDataType { 46 | name: string; 47 | description: string; 48 | nativeDataType: any; 49 | isSortable?: boolean; 50 | serialize?: StorageDataTypeSerializer; 51 | enforceSerialize?: boolean; 52 | parse?: StorageDataTypeParser; 53 | capabilities?: string[]; 54 | 55 | constructor(setup: StorageDataTypeSetup = {} as StorageDataTypeSetup) { 56 | const { 57 | name, 58 | description, 59 | nativeDataType, 60 | isSortable, 61 | serialize, 62 | enforceSerialize, 63 | parse, 64 | capabilities, 65 | } = setup; 66 | 67 | passOrThrow(name, () => 'Missing storage data type name'); 68 | passOrThrow( 69 | description, 70 | () => `Missing description for storage data type '${name}'`, 71 | ); 72 | passOrThrow( 73 | nativeDataType, 74 | () => `Missing native data type for storage data type '${name}'`, 75 | ); 76 | 77 | passOrThrow( 78 | isFunction(serialize), 79 | () => `Storage data type '${name}' has an invalid serialize function`, 80 | ); 81 | 82 | passOrThrow( 83 | !parse || isFunction(parse), 84 | () => `Storage data type '${name}' has an invalid parse function`, 85 | ); 86 | 87 | if (capabilities) { 88 | passOrThrow( 89 | isArray(capabilities), 90 | () => `Storage data type '${name}' has an invalid list of capabilities`, 91 | ); 92 | 93 | capabilities.map((capability) => { 94 | passOrThrow( 95 | storageDataTypeCapabilities[capability], 96 | () => 97 | `Storage data type '${name}' has an unknown capability '${capability}'`, 98 | ); 99 | }); 100 | } 101 | 102 | this.name = name; 103 | this.description = description; 104 | this.nativeDataType = nativeDataType; 105 | this.isSortable = !!isSortable; 106 | this.serialize = serialize; 107 | this.enforceSerialize = !!enforceSerialize; 108 | this.parse = parse || ((value: unknown) => value); 109 | this.capabilities = capabilities || []; 110 | } 111 | 112 | toString() { 113 | return this.name; 114 | } 115 | } 116 | 117 | export const isStorageDataType = (obj: unknown): obj is StorageDataType => { 118 | return obj instanceof StorageDataType; 119 | }; 120 | -------------------------------------------------------------------------------- /src/graphqlProtocol/__snapshots__/generator.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generator test should render GraphQL Schema 1`] = ` 4 | GraphQLSchema { 5 | "__validationErrors": undefined, 6 | "_directives": Array [ 7 | "@include", 8 | "@skip", 9 | "@deprecated", 10 | "@specifiedBy", 11 | ], 12 | "_implementationsMap": Object { 13 | "Node": Object { 14 | "interfaces": Array [], 15 | "objects": Array [ 16 | "TestEntityName", 17 | ], 18 | }, 19 | }, 20 | "_mutationType": "Mutation", 21 | "_queryType": "Query", 22 | "_subTypeMap": Object {}, 23 | "_subscriptionType": "Subscription", 24 | "_typeMap": Object { 25 | "Boolean": "Boolean", 26 | "CreateTestEntityNameInput": "CreateTestEntityNameInput", 27 | "CreateTestEntityNameInstanceInput": "CreateTestEntityNameInstanceInput", 28 | "CreateTestEntityNameInstanceNestedInput": "CreateTestEntityNameInstanceNestedInput", 29 | "CreateTestEntityNameNestedInput": "CreateTestEntityNameNestedInput", 30 | "CreateTestEntityNameOutput": "CreateTestEntityNameOutput", 31 | "Cursor": "Cursor", 32 | "DeleteTestEntityNameByIdInput": "DeleteTestEntityNameByIdInput", 33 | "DeleteTestEntityNameInput": "DeleteTestEntityNameInput", 34 | "DeleteTestEntityNameOutput": "DeleteTestEntityNameOutput", 35 | "ID": "ID", 36 | "Int": "Int", 37 | "Mutation": "Mutation", 38 | "Node": "Node", 39 | "OnCreateTestEntityNameInput": "OnCreateTestEntityNameInput", 40 | "OnCreateTestEntityNameInstanceInput": "OnCreateTestEntityNameInstanceInput", 41 | "OnCreateTestEntityNameInstanceNestedInput": "OnCreateTestEntityNameInstanceNestedInput", 42 | "OnCreateTestEntityNameNestedInput": "OnCreateTestEntityNameNestedInput", 43 | "OnCreateTestEntityNameOutput": "OnCreateTestEntityNameOutput", 44 | "OnDeleteTestEntityNameInput": "OnDeleteTestEntityNameInput", 45 | "OnDeleteTestEntityNameOutput": "OnDeleteTestEntityNameOutput", 46 | "OnUpdateTestEntityNameInput": "OnUpdateTestEntityNameInput", 47 | "OnUpdateTestEntityNameInstanceInput": "OnUpdateTestEntityNameInstanceInput", 48 | "OnUpdateTestEntityNameInstanceNestedInput": "OnUpdateTestEntityNameInstanceNestedInput", 49 | "OnUpdateTestEntityNameNestedInput": "OnUpdateTestEntityNameNestedInput", 50 | "OnUpdateTestEntityNameOutput": "OnUpdateTestEntityNameOutput", 51 | "PageInfo": "PageInfo", 52 | "Query": "Query", 53 | "String": "String", 54 | "Subscription": "Subscription", 55 | "TestEntityName": "TestEntityName", 56 | "TestEntityNameConnection": "TestEntityNameConnection", 57 | "TestEntityNameEdge": "TestEntityNameEdge", 58 | "TestEntityNameFilter": "TestEntityNameFilter", 59 | "TestEntityNameOrderBy": "TestEntityNameOrderBy", 60 | "UpdateTestEntityNameByIdInput": "UpdateTestEntityNameByIdInput", 61 | "UpdateTestEntityNameInput": "UpdateTestEntityNameInput", 62 | "UpdateTestEntityNameInstanceInput": "UpdateTestEntityNameInstanceInput", 63 | "UpdateTestEntityNameInstanceNestedInput": "UpdateTestEntityNameInstanceNestedInput", 64 | "UpdateTestEntityNameNestedInput": "UpdateTestEntityNameNestedInput", 65 | "UpdateTestEntityNameOutput": "UpdateTestEntityNameOutput", 66 | "__Directive": "__Directive", 67 | "__DirectiveLocation": "__DirectiveLocation", 68 | "__EnumValue": "__EnumValue", 69 | "__Field": "__Field", 70 | "__InputValue": "__InputValue", 71 | "__Schema": "__Schema", 72 | "__Type": "__Type", 73 | "__TypeKind": "__TypeKind", 74 | }, 75 | "astNode": undefined, 76 | "description": undefined, 77 | "extensionASTNodes": undefined, 78 | "extensions": undefined, 79 | } 80 | `; 81 | -------------------------------------------------------------------------------- /test/models/Website.ts: -------------------------------------------------------------------------------- 1 | import { invert } from 'lodash'; 2 | import { 3 | Entity, 4 | DataTypeString, 5 | Index, 6 | INDEX_UNIQUE, 7 | Mutation, 8 | MUTATION_TYPE_CREATE, 9 | Permission, 10 | DataTypeInteger, 11 | DataTypeEnum, 12 | DataTypeBoolean, 13 | MUTATION_TYPE_UPDATE, 14 | } from '../../src'; 15 | import { findOne } from '../db'; 16 | import { asUser } from '../testUtils'; 17 | 18 | export const categoryTypes = { 19 | NEWS: 10, 20 | CODING: 20, 21 | ENTERTAINMENT: 30, 22 | TUTORIALS: 40, 23 | MISC: 50, 24 | }; 25 | 26 | export const categoryTypesInverted = invert(categoryTypes); 27 | 28 | export const categoryTypesEnum = new DataTypeEnum({ 29 | name: 'CategoryType', 30 | values: categoryTypes, 31 | }); 32 | 33 | const readPermissions = [ 34 | new Permission().role('admin'), 35 | new Permission().userAttribute('createdBy'), 36 | ]; 37 | 38 | export const Website = new Entity({ 39 | name: 'Website', 40 | description: 'A website', 41 | 42 | includeUserTracking: true, 43 | 44 | indexes: [ 45 | new Index({ 46 | type: INDEX_UNIQUE, 47 | attributes: ['url'], 48 | }), 49 | ], 50 | 51 | mutations: [ 52 | new Mutation({ 53 | name: 'save', 54 | description: 'save a new website', 55 | type: MUTATION_TYPE_CREATE, 56 | attributes: ['url', 'category'], 57 | preProcessor({ input }) { 58 | const url = input.url as string; 59 | const category = input.category as string; 60 | const lucky = url === "I'm feeling lucky!"; 61 | 62 | if (lucky) { 63 | return { 64 | url: 'http://a.random.unsecured.news.website.com', 65 | category: categoryTypes.NEWS, 66 | }; 67 | } 68 | return { 69 | url, 70 | ...(category && { category }), 71 | }; 72 | }, 73 | }), 74 | new Mutation({ 75 | name: 'visit', 76 | description: 'visit a website and increase counter', 77 | type: MUTATION_TYPE_UPDATE, 78 | attributes: [], 79 | async preProcessor({ id, context: { userId } }) { 80 | const result = await findOne(Website, id, {}, asUser(userId)); 81 | return { 82 | visitCount: result.visitCount + 1, 83 | }; 84 | }, 85 | }), 86 | ], 87 | 88 | permissions: { 89 | read: readPermissions, 90 | find: readPermissions, 91 | mutations: { 92 | save: Permission.EVERYONE, 93 | }, 94 | }, 95 | 96 | attributes: { 97 | url: { 98 | type: DataTypeString, 99 | description: 'Website URL', 100 | required: true, 101 | validate: ({ value }) => { 102 | const url = value as string; 103 | 104 | if (!url.startsWith('http://') && !url.startsWith('https://')) { 105 | throw new Error('Unknown protocol, should be http:// or https://'); 106 | } 107 | }, 108 | }, 109 | 110 | visitCount: { 111 | type: DataTypeInteger, 112 | description: 'Number of times this website was visited', 113 | required: true, 114 | defaultValue() { 115 | return 0; 116 | }, 117 | }, 118 | 119 | category: { 120 | type: categoryTypesEnum, 121 | description: 'Category of website', 122 | required: true, 123 | defaultValue() { 124 | return categoryTypes.MISC; 125 | }, 126 | }, 127 | 128 | isSecure: { 129 | type: DataTypeBoolean, 130 | description: 'Is it using a secure protocol?', 131 | resolve: ({ obj }) => { 132 | const url = obj.url as string; 133 | return url.startsWith('https://'); 134 | }, 135 | }, 136 | }, 137 | }); 138 | -------------------------------------------------------------------------------- /test/data/profiles.csv: -------------------------------------------------------------------------------- 1 | annabel711,eveniet,Annabel,Bergstrom 2 | anya543,illum,Anya,Conn 3 | fleta342,inventore,Fleta,Williamson 4 | victor821,dolores,Victor,Keeling 5 | griffin807,corporis,Griffin,Frami 6 | jeremy386,voluptatum,Jeremy,Larkin 7 | autumn520,consequuntur,Autumn,Ullrich 8 | edward969,sed,Edward,Dach 9 | omari409,consequatur,Omari,White 10 | leon807,eos,Leon,Smitham 11 | hazel528,consectetur,Hazel,King 12 | emmet741,ea,Emmet,Boyle 13 | dana768,officiis,Dana,Thiel 14 | clark218,assumenda,Clark,Abshire 15 | athena557,neque,Athena,Stamm 16 | sedrick380,est,Sedrick,Nitzsche 17 | fred777,corrupti,Fred,Reichert 18 | skyla797,excepturi,Skyla,Dicki 19 | leif964,iure,Leif,Parker 20 | katrina560,non,Katrina,Roob 21 | weston422,itaque,Weston,Borer 22 | bridget650,placeat,Bridget,Hauck 23 | quincy332,qui,Quincy,Haag 24 | aida285,quia,Aida,Lakin 25 | tillman582,aspernatur,Tillman,Kihn 26 | araceli794,tenetur,Araceli,Lind 27 | rosella168,illo,Rosella,Kessler 28 | sasha907,dolorum,Sasha,Ullrich 29 | wilmer687,officiis,Wilmer,Berge 30 | carissa722,sint,Carissa,Nitzsche 31 | alice157,molestiae,Alice,Howell 32 | jeramy671,nisi,Jeramy,Heaney 33 | hiram985,deleniti,Hiram,Marvin 34 | arlie128,harum,Arlie,Kemmer 35 | kareem654,non,Kareem,Strosin 36 | jaida791,eaque,Jaida,Prosacco 37 | joe656,quis,Joe,Quitzon 38 | macy375,qui,Macy,Buckridge 39 | angeline447,explicabo,Angeline,Tremblay 40 | carey154,et,Carey,Bailey 41 | myrl112,deleniti,Myrl,Mueller 42 | alva280,nisi,Alva,Jacobson 43 | stephen528,et,Stephen,Muller 44 | freda641,nostrum,Freda,Goldner 45 | salvador975,quos,Salvador,Corwin 46 | antonia919,veniam,Antonia,Gutkowski 47 | theodore123,atque,Theodore,Ritchie 48 | joseph599,et,Joseph,Nitzsche 49 | nettie731,iure,Nettie,Kunze 50 | frank367,eum,Frank,Stroman 51 | melody153,velit,Melody,Borer 52 | reilly384,quia,Reilly,Lang 53 | emelie578,deleniti,Emelie,Schamberger 54 | shaina180,excepturi,Shaina,Schneider 55 | hailie632,illo,Hailie,Prosacco 56 | sallie390,molestiae,Sallie,Schuppe 57 | felipe637,enim,Felipe,Kassulke 58 | eladio832,laborum,Eladio,Botsford 59 | kara119,quia,Kara,Treutel 60 | christa878,error,Christa,Terry 61 | bernardo410,consequatur,Bernardo,VonRueden 62 | kraig322,ut,Kraig,White 63 | sibyl966,accusamus,Sibyl,Bahringer 64 | brando475,rerum,Brando,Koepp 65 | amalia943,cumque,Amalia,Stamm 66 | daren964,assumenda,Daren,Pouros 67 | jonathon586,tempore,Jonathon,Gusikowski 68 | ryan372,dicta,Ryan,Kuphal 69 | antone347,et,Antone,Schmitt 70 | estell684,non,Estell,Waters 71 | jose821,earum,Jose,Leffler 72 | alvera707,architecto,Alvera,Miller 73 | elias993,ipsa,Elias,Koch 74 | trent383,quis,Trent,Schumm 75 | bryana562,repellendus,Bryana,Stanton 76 | stephany128,id,Stephany,Strosin 77 | conor143,at,Conor,Romaguera 78 | tiana281,eos,Tiana,Schamberger 79 | joe922,at,Joe,Rohan 80 | serenity662,eos,Serenity,Stamm 81 | evalyn659,voluptas,Evalyn,Bernhard 82 | dortha300,esse,Dortha,Hane 83 | heber123,doloribus,Heber,Crona 84 | anne750,voluptatem,Anne,Wyman 85 | karelle861,recusandae,Karelle,Runolfsson 86 | laurianne153,autem,Laurianne,Torphy 87 | jerel443,ab,Jerel,Dooley 88 | hanna952,reprehenderit,Hanna,Jerde 89 | dejuan932,iste,Dejuan,Schoen 90 | emilia189,ut,Emilia,Marks 91 | lionel825,quibusdam,Lionel,Hessel 92 | retha124,minus,Retha,Thompson 93 | marianne699,quia,Marianne,Sauer 94 | benton354,odit,Benton,Wunsch 95 | joel356,sint,Joel,Lakin 96 | faustino795,id,Faustino,Bartell 97 | xander627,adipisci,Xander,Sipes 98 | elza510,unde,Elza,Gorczany 99 | antonina448,molestias,Antonina,Lesch 100 | vernice306,nesciunt,Vernice,Sipes 101 | isidro418,nihil,Isidro,Heaney 102 | garfield347,facere,Garfield,Langosh 103 | ella377,et,Ella,Roberts 104 | joe154,eveniet,Joe,Ratke 105 | brandi646,quis,Brandi,Grimes 106 | stan321,temporibus,Stan,VonRueden 107 | leanna128,numquam,Leanna,Leuschke 108 | tianna226,corrupti,Tianna,Kulas 109 | muriel707,quaerat,Muriel,Howe 110 | krystel170,facere,Krystel,Bauch 111 | -------------------------------------------------------------------------------- /src/engine/index/Index.ts: -------------------------------------------------------------------------------- 1 | import { uniq } from 'lodash'; 2 | import { passOrThrow, isArray } from '../util'; 3 | 4 | import { Entity } from '../entity/Entity'; 5 | 6 | export const INDEX_UNIQUE = 'unique'; 7 | export const INDEX_GENERIC = 'generic'; 8 | export const indexTypes = [INDEX_UNIQUE, INDEX_GENERIC]; 9 | 10 | export type IndexSetup = { 11 | type?: string; 12 | attributes?: string[]; 13 | }; 14 | 15 | export class Index { 16 | type: string; 17 | attributes: string[]; 18 | 19 | constructor(setup: IndexSetup = {} as IndexSetup) { 20 | const { type, attributes } = setup; 21 | 22 | passOrThrow(type, () => 'Missing index type'); 23 | passOrThrow( 24 | indexTypes.indexOf(type) >= 0, 25 | () => 26 | `Unknown index type '${type}' used, try one of these: '${indexTypes.join( 27 | ', ', 28 | )}'`, 29 | ); 30 | 31 | passOrThrow( 32 | isArray(attributes, true), 33 | () => 34 | `Index definition of type '${type}' needs to have a list of attributes`, 35 | ); 36 | 37 | attributes.map((attribute) => { 38 | passOrThrow( 39 | typeof attribute === 'string', 40 | () => 41 | `Index definition of type '${type}' needs to have a list of attribute names`, 42 | ); 43 | }); 44 | 45 | passOrThrow( 46 | attributes.length === uniq(attributes).length, 47 | () => 48 | `Index definition of type '${type}' needs to have a list of unique attribute names`, 49 | ); 50 | 51 | this.type = type; 52 | this.attributes = attributes; 53 | } 54 | 55 | toString() { 56 | return this.type; 57 | } 58 | } 59 | 60 | export const isIndex = (obj: unknown): obj is Index => { 61 | return obj instanceof Index; 62 | }; 63 | 64 | export const processEntityIndexes = (entity: Entity, indexes: Index[]) => { 65 | passOrThrow( 66 | isArray(indexes), 67 | () => 68 | `Entity '${entity.name}' indexes definition needs to be an array of indexes`, 69 | ); 70 | 71 | const singleAttributeIndexes = []; 72 | 73 | const entityAttributes = entity.getAttributes(); 74 | 75 | indexes.map((index, idx) => { 76 | passOrThrow( 77 | isIndex(index), 78 | () => 79 | `Invalid index definition for entity '${entity.name}' at position '${idx}'`, 80 | ); 81 | 82 | index.attributes.map((attributeName) => { 83 | passOrThrow( 84 | entityAttributes[attributeName], 85 | () => 86 | `Cannot use attribute '${entity.name}.${attributeName}' in index as it does not exist`, 87 | ); 88 | 89 | if (index.type === INDEX_UNIQUE) { 90 | passOrThrow( 91 | entityAttributes[attributeName].required, 92 | () => 93 | `Cannot use attribute '${entity.name}.${attributeName}' in uniqueness index as it is nullable`, 94 | ); 95 | 96 | passOrThrow( 97 | !entityAttributes[attributeName].i18n, 98 | () => 99 | `Cannot use attribute '${entity.name}.${attributeName}' in uniqueness index as it is translatable`, 100 | ); 101 | 102 | if (index.attributes.length === 1) { 103 | entityAttributes[attributeName].unique = true; 104 | } 105 | } 106 | 107 | if (index.attributes.length === 1) { 108 | singleAttributeIndexes.push(attributeName); 109 | } 110 | }); 111 | }); 112 | 113 | // add new index for single attributes with the 'index' flag set but not defined in an index object by the user 114 | Object.keys(entityAttributes).map((attributeName) => { 115 | if (entityAttributes[attributeName].index) { 116 | if (!singleAttributeIndexes.includes(attributeName)) { 117 | indexes.push( 118 | new Index({ 119 | type: INDEX_GENERIC, 120 | attributes: [attributeName], 121 | }), 122 | ); 123 | } 124 | } 125 | }); 126 | 127 | return indexes; 128 | }; 129 | -------------------------------------------------------------------------------- /src/graphqlProtocol/query.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLNonNull, GraphQLID } from 'graphql'; 2 | import * as _ from 'lodash'; 3 | import { ProtocolGraphQL } from './ProtocolGraphQL'; 4 | import { ProtocolGraphQLConfiguration } from './ProtocolGraphQLConfiguration'; 5 | 6 | import { resolveByFind, resolveByFindOne } from './resolver'; 7 | import { isEntity } from '../engine/entity/Entity'; 8 | import { isViewEntity } from '../engine/entity/ViewEntity'; 9 | import { getRegisteredEntity, getRegisteredEntityAttribute } from './registry'; 10 | 11 | export const generateListQueries = (graphRegistry) => { 12 | const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; 13 | 14 | const listQueries = {}; 15 | 16 | _.forEach(graphRegistry.types, ({ entity }, typeName) => { 17 | if (!isEntity(entity) && !isViewEntity(entity)) { 18 | return; 19 | } 20 | 21 | const { 22 | typeNamePluralPascalCase: typeNamePluralListName, 23 | } = getRegisteredEntity(entity.name); 24 | const queryName = protocolConfiguration.generateListQueryTypeName(entity); 25 | 26 | listQueries[queryName] = { 27 | type: graphRegistry.types[typeName].connection, 28 | description: `Fetch a list of **\`${typeNamePluralListName}\`**\n${ 29 | entity.descriptionPermissionsFind || '' 30 | }`, 31 | args: graphRegistry.types[typeName].connectionArgs, 32 | resolve: resolveByFind(entity), 33 | }; 34 | }); 35 | 36 | return listQueries; 37 | }; 38 | 39 | export const generateInstanceQueries = (graphRegistry, idFetcher) => { 40 | const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; 41 | const instanceQueries = {}; 42 | 43 | _.forEach(graphRegistry.types, ({ type, entity }) => { 44 | if (!isEntity(entity) && !isViewEntity(entity)) { 45 | return; 46 | } 47 | 48 | const { typeNamePascalCase } = getRegisteredEntity(entity.name); 49 | const queryName = protocolConfiguration.generateInstanceQueryTypeName( 50 | entity, 51 | ); 52 | 53 | instanceQueries[queryName] = { 54 | type: type, 55 | description: `Fetch a single **\`${typeNamePascalCase}\`** using its node ID\n${ 56 | entity.descriptionPermissionsRead || '' 57 | }`, 58 | args: { 59 | nodeId: { 60 | type: new GraphQLNonNull(GraphQLID), 61 | }, 62 | }, 63 | resolve: (_source, { nodeId }, context, info) => 64 | idFetcher(nodeId, context, info), 65 | }; 66 | 67 | // find the primary attribute and add a query for it 68 | const attributes = entity.getAttributes(); 69 | const primaryAttributeName = _.findKey(attributes, { primary: true }); 70 | 71 | if (primaryAttributeName) { 72 | const primaryAttribute = attributes[primaryAttributeName]; 73 | 74 | const { fieldName } = getRegisteredEntityAttribute( 75 | entity.name, 76 | // TODO: name does not exist on AttributeBase 77 | // eslint-disable-next-line dot-notation 78 | primaryAttribute['name'], 79 | ); 80 | 81 | const graphqlDataType = ProtocolGraphQL.convertToProtocolDataType( 82 | primaryAttribute.type, 83 | entity.name, 84 | false, 85 | ); 86 | const queryNamePrimaryAttribute = protocolConfiguration.generateInstanceByUniqueQueryTypeName( 87 | entity, 88 | primaryAttribute, 89 | ); 90 | 91 | instanceQueries[queryNamePrimaryAttribute] = { 92 | type: type, 93 | description: `Fetch a single **\`${typeNamePascalCase}\`** using its **\`${fieldName}\`**\n${ 94 | entity.descriptionPermissionsRead || '' 95 | }`, 96 | args: { 97 | [fieldName]: { 98 | type: new GraphQLNonNull(graphqlDataType), 99 | }, 100 | }, 101 | resolve: resolveByFindOne(entity, ({ args }) => args[fieldName]), 102 | }; 103 | } 104 | }); 105 | 106 | return instanceQueries; 107 | }; 108 | -------------------------------------------------------------------------------- /test/data/messages.csv: -------------------------------------------------------------------------------- 1 | occaecati occaecati saepe hic sit voluptatem quia,14,2,2018-03-26T20:47:42.236Z 2 | eum dignissimos aspernatur consequatur repellendus perferendis sunt,83,41,2018-03-26T20:47:40.681Z 3 | sit provident quasi voluptates sequi dolor et 😀,48,22,2018-03-26T20:47:43.474Z 4 | quia optio cumque velit voluptates nostrum ipsa 🙌,76,39,2018-03-26T20:47:44.909Z 5 | ad ullam sit recusandae aut itaque et,86,14,2018-03-26T20:47:41.966Z 6 | iste error eum impedit aut earum iusto 😜,11,7,2018-03-26T20:47:46.743Z 7 | velit magnam nobis aspernatur corporis voluptas sed 😀,7,26,2018-03-26T20:47:46.578Z 8 | nihil impedit magni accusamus et voluptas eius 😍,46,17,2018-03-26T20:47:42.948Z 9 | doloremque quia ut temporibus nulla culpa laboriosam ✅,65,43,2018-03-26T20:47:41.743Z 10 | nihil quibusdam perferendis omnis maiores saepe voluptas,18,18,2018-03-26T20:47:41.347Z 11 | voluptatem vero optio mollitia et qui nihil,65,37,2018-03-26T20:47:46.084Z 12 | voluptatum eius deleniti et consequatur qui architecto 😀,88,47,2018-03-26T20:47:45.854Z 13 | non ut perspiciatis assumenda nostrum nostrum architecto 🍪,75,26,2018-03-26T20:47:39.211Z 14 | molestiae illum quo consequuntur eos earum deserunt,65,35,2018-03-26T20:47:43.632Z 15 | vel aperiam ipsa rerum non sunt doloremque,24,9,2018-03-26T20:47:40.831Z 16 | consectetur deleniti qui perspiciatis eum explicabo harum 😎,83,31,2018-03-26T20:47:44.162Z 17 | molestiae nihil sint id ea ea tenetur,63,33,2018-03-26T20:47:41.614Z 18 | qui consequatur quasi qui ut dolorum et 🍕,47,30,2018-03-26T20:47:48.240Z 19 | aspernatur omnis quia dolores deleniti dolorem et,88,35,2018-03-26T20:47:42.991Z 20 | fugiat quis a a debitis et culpa,54,43,2018-03-26T20:47:46.578Z 21 | est veritatis sed non dolor et voluptas 👌,76,33,2018-03-26T20:47:45.402Z 22 | ipsa debitis distinctio molestias voluptatem excepturi aut,11,25,2018-03-26T20:47:41.580Z 23 | qui et aut fuga quisquam debitis temporibus 😊,66,20,2018-03-26T20:47:45.117Z 24 | vero reprehenderit vitae suscipit veritatis doloremque consequatur,46,36,2018-03-26T20:47:40.094Z 25 | blanditiis dolorum dolorem optio dolores tempora quia 🙌,72,19,2018-03-26T20:47:43.762Z 26 | omnis deserunt nemo est in ipsam molestias,68,32,2018-03-26T20:47:47.096Z 27 | ut sequi rem quia architecto autem non 😍,87,8,2018-03-26T20:47:39.122Z 28 | non ea et nesciunt fugiat dolorem rerum,53,14,2018-03-26T20:47:47.417Z 29 | ut distinctio sint dolores ut est corrupti,59,12,2018-03-26T20:47:46.422Z 30 | quae optio repellendus minima reprehenderit et et 😆,31,6,2018-03-26T20:47:41.523Z 31 | aut nihil natus iste eos natus in ✅,45,45,2018-03-26T20:47:47.837Z 32 | ipsum ex placeat quos omnis dolorem dolorem,71,23,2018-03-26T20:47:46.740Z 33 | est repellat necessitatibus ipsa nisi vero numquam 🚀,48,44,2018-03-26T20:47:47.754Z 34 | nobis qui beatae minus incidunt consequatur eos 😂,84,24,2018-03-26T20:47:48.487Z 35 | sit culpa necessitatibus sit culpa tempora labore 😜,40,49,2018-03-26T20:47:42.153Z 36 | error qui at nemo magni dolorem quae 😍,36,18,2018-03-26T20:47:39.752Z 37 | molestiae eos omnis omnis enim vel sed 🍪,17,7,2018-03-26T20:47:46.667Z 38 | enim vel et rem labore amet recusandae 🙌,88,26,2018-03-26T20:47:46.556Z 39 | autem qui inventore sapiente aliquid ea nisi,42,14,2018-03-26T20:47:43.027Z 40 | expedita eveniet dicta deleniti aut distinctio molestiae 😆,78,30,2018-03-26T20:47:46.578Z 41 | consequatur fugiat a nobis voluptas velit sed,85,36,2018-03-26T20:47:43.298Z 42 | consequatur vel sint asperiores quisquam voluptatem blanditiis,52,3,2018-03-26T20:47:41.694Z 43 | eum deserunt dolores sed consequatur aut autem,48,37,2018-03-26T20:47:48.518Z 44 | quam sed deleniti rerum minus ipsa blanditiis 🍪,52,22,2018-03-26T20:47:48.492Z 45 | voluptatem non sapiente recusandae inventore architecto facilis 😆,14,13,2018-03-26T20:47:47.026Z 46 | debitis et voluptatem consequatur velit atque nesciunt,13,20,2018-03-26T20:47:42.980Z 47 | et ut qui eum et consequatur numquam 😆,28,13,2018-03-26T20:47:46.480Z 48 | deserunt voluptates eum aut libero nam sit 🚀,25,48,2018-03-26T20:47:46.519Z 49 | qui ratione asperiores excepturi dolorem eos sunt 🍪,64,30,2018-03-26T20:47:43.016Z 50 | aut ab enim nam modi qui corporis,40,26,2018-03-26T20:47:39.655Z 51 | -------------------------------------------------------------------------------- /src/storage-connector/i18n.ts: -------------------------------------------------------------------------------- 1 | import StorageTypePostgres from './StorageTypePostgres'; 2 | import { CustomError } from '..'; 3 | import { 4 | StorageDataTypeSerializer, 5 | StorageDataTypeParser, 6 | } from '../engine/storage/StorageDataType'; 7 | 8 | export const i18nDataParser: StorageDataTypeParser = ( 9 | _, 10 | // why ts-ignore? dataShaperMap does not exist on type RegistryEntity 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 12 | // @ts-ignore 13 | { data, entity, model: { dataShaperMap } }, 14 | ) => { 15 | const i18nAttributeNames = entity.getI18nAttributeNames(); 16 | 17 | if (!i18nAttributeNames || !data) { 18 | return data; 19 | } 20 | 21 | const languages = StorageTypePostgres.getStorageConfiguration() 22 | .getParentConfiguration() 23 | .getLanguages(); 24 | 25 | i18nAttributeNames.map((attributeName) => { 26 | const attributeStorageName = dataShaperMap[attributeName]; 27 | 28 | const i18nValues = data.i18n ? data.i18n[attributeStorageName] || {} : {}; 29 | 30 | languages.map((language, langIdx) => { 31 | const key = `${attributeName}.i18n`; 32 | 33 | data[key] = data[key] || {}; 34 | 35 | if (langIdx === 0) { 36 | data[key][language] = data[attributeName]; 37 | } else { 38 | data[key][language] = i18nValues[language]; 39 | } 40 | }); 41 | }); 42 | 43 | return null; 44 | }; 45 | 46 | export const i18nDataSerializer: StorageDataTypeSerializer = ( 47 | _, 48 | // why ts-ignore? dataShaperMap does not exist on type RegistryEntity 49 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 50 | // @ts-ignore 51 | { data, entity, mutation, model: { dataShaperMap } }, 52 | ): Record<string, unknown> => { 53 | const i18nAttributeNames = entity.getI18nAttributeNames(); 54 | 55 | if (!i18nAttributeNames || !data) { 56 | return data; 57 | } 58 | 59 | const result = {}; 60 | 61 | const languages = StorageTypePostgres.getStorageConfiguration() 62 | .getParentConfiguration() 63 | .getLanguages(); 64 | 65 | i18nAttributeNames.map((attributeName) => { 66 | const key = `${attributeName}.i18n`; 67 | if (!data[key]) { 68 | return; 69 | } 70 | 71 | Object.keys(data[key]).map((language) => { 72 | if (!languages.includes(language)) { 73 | throw new CustomError( 74 | `Unknown language '${language}' provided in translation of mutation '${mutation.name}'`, 75 | 'I18nError', 76 | ); 77 | } 78 | }); 79 | 80 | const attributeStorageName = dataShaperMap[attributeName]; 81 | 82 | languages.map((language, langIdx) => { 83 | if (langIdx === 0) { 84 | if (typeof data[attributeName] === 'undefined') { 85 | if (typeof data[key][language] !== 'undefined') { 86 | data[attributeName] = data[key][language]; 87 | } 88 | } 89 | } else { 90 | if (typeof data[key][language] !== 'undefined') { 91 | result[attributeStorageName] = result[attributeStorageName] || {}; 92 | result[attributeStorageName][language] = data[key][language]; 93 | } 94 | } 95 | }); 96 | }); 97 | 98 | return result; 99 | }; 100 | 101 | export const i18nTransformFilterAttributeName = ( 102 | context, 103 | entity, 104 | modelRegistry, 105 | ) => { 106 | if (!context || !entity || !modelRegistry) { 107 | return (attributeName) => attributeName; 108 | } 109 | 110 | const { i18nLanguage, i18nDefaultLanguage } = context; 111 | 112 | const attributes = entity.getAttributes(); 113 | 114 | const { reverseDataShaperMap } = modelRegistry[entity.name]; 115 | 116 | return (storageAttributeName) => { 117 | const attributeName = reverseDataShaperMap[storageAttributeName]; 118 | const attribute = attributes[attributeName]; 119 | 120 | if (!attribute || !attribute.i18n || i18nLanguage === i18nDefaultLanguage) { 121 | return storageAttributeName; 122 | } 123 | 124 | return `i18n->'${storageAttributeName}'->>'${i18nLanguage}'`; 125 | }; 126 | }; 127 | -------------------------------------------------------------------------------- /src/engine/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { Entity, Subscription } from '..'; 3 | import { getRegisteredEntityAttribute } from '../graphqlProtocol/registry'; 4 | import { Attribute } from './attribute/Attribute'; 5 | import { Context } from './context/Context'; 6 | import { Mutation } from './mutation/Mutation'; 7 | 8 | export const fillSystemAttributesDefaultValues = ( 9 | entity: Entity, 10 | operation: Mutation | Subscription, 11 | payload: Record<string, unknown>, 12 | context: Context, 13 | ): Record<string, unknown> => { 14 | const ret = { 15 | ...payload, 16 | }; 17 | 18 | const entityAttributes: any = entity.getAttributes(); 19 | const systemAttributes = _.filter( 20 | entityAttributes, 21 | (attribute) => attribute.isSystemAttribute && attribute.defaultValue, 22 | ); 23 | 24 | systemAttributes.map((attribute: Attribute) => { 25 | const attributeName = attribute.name; 26 | const defaultValue = attribute.defaultValue; 27 | 28 | const value = defaultValue({ payload, operation, entity, context }); 29 | if (typeof value !== 'undefined') { 30 | ret[attributeName] = value; 31 | } 32 | }); 33 | 34 | return ret; 35 | }; 36 | 37 | export const fillDefaultValues = async ( 38 | entity: Entity, 39 | entityMutation: Mutation, 40 | payload: any, 41 | context: Record<string, any>, 42 | ): Promise<any> => { 43 | const ret = { 44 | ...payload, 45 | }; 46 | 47 | const entityAttributes = entity.getAttributes(); 48 | const payloadAttributes = Object.keys(payload); 49 | // TODO: isSystemAttribute does not exist? do we create it? 50 | const requiredAttributes = _.filter( 51 | entityAttributes, 52 | // eslint-disable-next-line dot-notation 53 | (attribute) => attribute.required && !attribute['isSystemAttribute'], 54 | ); 55 | 56 | await Promise.all( 57 | requiredAttributes.map(async (attribute) => { 58 | // TODO: name does not exist on Attribute base 59 | // eslint-disable-next-line dot-notation 60 | const attributeName = attribute['name']; 61 | if (!payloadAttributes.includes(attributeName)) { 62 | if (attribute.defaultValue) { 63 | ret[attributeName] = await attribute.defaultValue({ 64 | payload, 65 | operation: entityMutation, 66 | entity, 67 | context, 68 | }); 69 | } 70 | } 71 | }), 72 | ); 73 | 74 | return ret; 75 | }; 76 | 77 | export const serializeValues = ( 78 | entity: Entity, 79 | entityMutation: Mutation, 80 | payload: Record<string, unknown>, 81 | model: string, 82 | context: Record<string, any>, 83 | ): any => { 84 | const ret = { 85 | ...payload, 86 | }; 87 | 88 | const entityAttributes = entity.getAttributes(); 89 | 90 | _.forEach(entityAttributes, (attribute) => { 91 | // TODO: name does not exist on AttributeBase 92 | // eslint-disable-next-line dot-notation 93 | const attributeName = attribute['name']; 94 | const { fieldNameI18n: gqlFieldNameI18n } = getRegisteredEntityAttribute( 95 | entity.name, 96 | // TODO: name does not exist on AttributeBase 97 | // eslint-disable-next-line dot-notation 98 | attribute['name'], 99 | ); 100 | 101 | if (attribute.serialize) { 102 | if (attribute.i18n && typeof ret[gqlFieldNameI18n] !== 'undefined') { 103 | Object.keys(ret[gqlFieldNameI18n]).map((language) => { 104 | ret[gqlFieldNameI18n][language] = attribute.serialize( 105 | ret[gqlFieldNameI18n][language], 106 | ret, 107 | entityMutation, 108 | entity, 109 | model, 110 | context, 111 | language, 112 | ); 113 | }); 114 | } else if (typeof ret[attributeName] !== 'undefined') { 115 | ret[attributeName] = attribute.serialize( 116 | ret[attributeName], 117 | ret, 118 | entityMutation, 119 | entity, 120 | model, 121 | context, 122 | ); 123 | } 124 | } 125 | }); 126 | 127 | return ret; 128 | }; 129 | -------------------------------------------------------------------------------- /test/models/BoardMember.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Mutation, 4 | MUTATION_TYPE_CREATE, 5 | MUTATION_TYPE_UPDATE, 6 | MUTATION_TYPE_DELETE, 7 | Permission, 8 | Index, 9 | INDEX_UNIQUE, 10 | INDEX_GENERIC, 11 | } from '../../src'; 12 | 13 | import { Profile } from './Profile'; 14 | import { Board } from './Board'; 15 | 16 | const readPermissions = () => [ 17 | new Permission().role('admin'), 18 | new Permission().userAttribute('inviter').userAttribute('invitee'), 19 | new Permission().lookup(Board, { 20 | id: 'board', 21 | owner: ({ userId }) => userId, 22 | }), 23 | // eslint-disable-next-line no-use-before-define 24 | new Permission().lookup(BoardMember, { 25 | board: 'board', 26 | invitee: ({ userId }) => userId, 27 | }), 28 | ]; 29 | 30 | export const BoardMember = new Entity({ 31 | name: 'BoardMember', 32 | description: 'BoardMember of a private board', 33 | 34 | includeTimeTracking: true, 35 | 36 | indexes: [ 37 | new Index({ 38 | type: INDEX_UNIQUE, 39 | attributes: ['board', 'inviter', 'invitee'], 40 | }), 41 | new Index({ 42 | type: INDEX_GENERIC, 43 | attributes: ['board', 'invitee'], 44 | }), 45 | ], 46 | 47 | states: { 48 | invited: 10, 49 | accepted: 20, 50 | joined: 50, 51 | }, 52 | 53 | mutations: ({ createMutation }) => [ 54 | createMutation, 55 | 56 | new Mutation({ 57 | name: 'join', 58 | description: 'join an open board', 59 | type: MUTATION_TYPE_CREATE, 60 | toState: 'joined', 61 | attributes: ['board'], 62 | }), 63 | 64 | new Mutation({ 65 | name: 'invite', 66 | description: 'invite a user to a private board', 67 | type: MUTATION_TYPE_CREATE, 68 | toState: 'invited', 69 | attributes: ['board', 'invitee'], 70 | }), 71 | new Mutation({ 72 | name: 'accept', 73 | description: 'accept an invitation to a private board', 74 | type: MUTATION_TYPE_UPDATE, 75 | fromState: 'invited', 76 | toState: 'accepted', 77 | attributes: [], 78 | }), 79 | new Mutation({ 80 | name: 'remove', 81 | description: 'remove a user from a private board', 82 | type: MUTATION_TYPE_DELETE, 83 | fromState: ['joined', 'invited', 'accepted'], 84 | }), 85 | new Mutation({ 86 | name: 'leave', 87 | description: 'leave a board', 88 | type: MUTATION_TYPE_DELETE, 89 | fromState: ['joined', 'invited', 'accepted'], 90 | }), 91 | ], 92 | 93 | permissions: () => ({ 94 | read: readPermissions(), 95 | find: readPermissions(), 96 | mutations: { 97 | join: [ 98 | new Permission().lookup(Board, { 99 | id: ({ input }) => input.board, 100 | isPrivate: () => false, 101 | }), 102 | ], 103 | invite: [ 104 | new Permission().lookup(Board, { 105 | id: ({ input }) => input.board, 106 | owner: ({ userId }) => userId, 107 | isPrivate: () => true, 108 | }), 109 | ], 110 | remove: [ 111 | new Permission().lookup(Board, { 112 | id: 'board', 113 | owner: ({ userId }) => userId, 114 | }), 115 | ], 116 | leave: [new Permission().userAttribute('invitee')], 117 | accept: [new Permission().userAttribute('invitee')], 118 | }, 119 | }), 120 | 121 | attributes: { 122 | board: { 123 | type: Board, 124 | description: 'Reference to the board', 125 | required: true, 126 | }, 127 | 128 | inviter: { 129 | type: Profile, 130 | description: 'The user that invites to a board', 131 | required: true, 132 | defaultValue({ context: { userId } }) { 133 | return userId; 134 | }, 135 | }, 136 | 137 | invitee: { 138 | type: Profile, 139 | description: 'The user that participates in the board', 140 | required: true, 141 | defaultValue({ operation: mutation, context: { userId } }) { 142 | if (mutation.name === 'join') { 143 | return userId; 144 | } 145 | 146 | return null; 147 | }, 148 | }, 149 | }, 150 | }); 151 | -------------------------------------------------------------------------------- /src/engine/datatype/dataTypes.ts: -------------------------------------------------------------------------------- 1 | import { DataType } from './DataType'; 2 | import { DataTypeUser } from './DataTypeUser'; 3 | import { randomJson } from '../util'; 4 | import * as casual from 'casual'; 5 | 6 | export const DataTypeUserID = new DataTypeUser({ 7 | name: 'DataTypeUserID', 8 | description: 'Data type representing a reference to a user', 9 | /* istanbul ignore next */ 10 | mock: () => casual.integer(2 ^ 20, 2 ^ 51).toString(), 11 | }); 12 | 13 | export const DataTypeID = new DataType({ 14 | name: 'DataTypeID', 15 | description: 'Data type representing unique IDs', 16 | /* istanbul ignore next */ 17 | mock: () => casual.integer(2 ^ 20, 2 ^ 51).toString(), 18 | }); 19 | 20 | export const DataTypeInteger = new DataType({ 21 | name: 'DataTypeInteger', 22 | description: 'Data type representing integer values', 23 | /* istanbul ignore next */ 24 | mock: () => casual.integer(-2 ^ 10, 2 ^ 10), 25 | }); 26 | 27 | export const DataTypeBigInt = new DataType({ 28 | name: 'DataTypeBigInt', 29 | description: 'Data type representing big integer values', 30 | /* istanbul ignore next */ 31 | mock: () => casual.integer(2 ^ 20, 2 ^ 51).toString(), 32 | }); 33 | 34 | export const DataTypeFloat = new DataType({ 35 | name: 'DataTypeFloat', 36 | description: 'Data type representing float values', 37 | /* istanbul ignore next */ 38 | mock: () => casual.double(-2 ^ 10, 2 ^ 10), 39 | }); 40 | 41 | export const DataTypeDouble = new DataType({ 42 | name: 'DataTypeDouble', 43 | description: 'Data type representing double precision values', 44 | /* istanbul ignore next */ 45 | mock: () => casual.double(-2 ^ 10, 2 ^ 10), 46 | }); 47 | 48 | export const DataTypeBoolean = new DataType({ 49 | name: 'DataTypeBoolean', 50 | description: 'Data type representing boolean values', 51 | /* istanbul ignore next */ 52 | mock: () => casual.boolean, 53 | enforceRequired: true, 54 | defaultValue() { 55 | return false; 56 | }, 57 | }); 58 | 59 | export const DataTypeString = new DataType({ 60 | name: 'DataTypeString', 61 | description: 'Data type representing text values', 62 | /* istanbul ignore next */ 63 | mock: () => casual.title, 64 | }); 65 | 66 | export const DataTypeJson = new DataType({ 67 | name: 'DataTypeJson', 68 | description: 'Data type representing json objects', 69 | /* istanbul ignore next */ 70 | mock: randomJson, 71 | }); 72 | 73 | export const DataTypeTimestamp = new DataType({ 74 | name: 'DataTypeTimestamp', 75 | description: 'Data type representing a timestamp', 76 | /* istanbul ignore next */ 77 | mock: () => new Date(casual.unix_time * 1000), 78 | }); 79 | 80 | export const DataTypeTimestampTz = new DataType({ 81 | name: 'DataTypeTimestampTz', 82 | description: 'Data type representing a timestamp with time zone information', 83 | /* istanbul ignore next */ 84 | mock: () => new Date(casual.unix_time * 1000), 85 | }); 86 | 87 | export const DataTypeDate = new DataType({ 88 | name: 'DataTypeDate', 89 | description: 'Data type representing a date', 90 | /* istanbul ignore next */ 91 | mock: () => new Date(casual.unix_time * 1000), 92 | }); 93 | 94 | export const DataTypeTime = new DataType({ 95 | name: 'DataTypeTime', 96 | description: 'Data type representing time', 97 | /* istanbul ignore next */ 98 | mock: () => new Date(casual.unix_time * 1000), 99 | }); 100 | 101 | export const DataTypeTimeTz = new DataType({ 102 | name: 'DataTypeTimeTz', 103 | description: 'Data type representing time with time zone information', 104 | /* istanbul ignore next */ 105 | mock: () => new Date(casual.unix_time * 1000), 106 | }); 107 | 108 | export const DataTypeUUID = new DataType({ 109 | name: 'DataTypeUUID', 110 | description: 'Data type representing a UUID', 111 | /* istanbul ignore next */ 112 | mock: () => casual.uuid, 113 | }); 114 | 115 | export const DataTypeI18n = new DataType({ 116 | name: 'DataTypeI18n', 117 | description: 'Data type representing translation objects', 118 | /* istanbul ignore next */ 119 | mock: randomJson, 120 | }); 121 | 122 | export const DataTypeUpload = new DataType({ 123 | name: 'DataTypeUpload', 124 | description: 'Data type representing a file upload', 125 | mock: () => null, 126 | }); 127 | -------------------------------------------------------------------------------- /src/engine/configuration/Configuration.ts: -------------------------------------------------------------------------------- 1 | import { passOrThrow, isArray } from '../util'; 2 | 3 | import { Schema, isSchema } from '../schema/Schema'; 4 | import { languageIsoCodeRegex, LANGUAGE_ISO_CODE_PATTERN } from '../constants'; 5 | import { 6 | ProtocolConfiguration, 7 | isProtocolConfiguration, 8 | } from '../protocol/ProtocolConfiguration'; 9 | import { 10 | StorageConfiguration, 11 | isStorageConfiguration, 12 | } from '../storage/StorageConfiguration'; 13 | 14 | import * as _ from 'lodash'; 15 | 16 | export type ConfigurationSetup = { 17 | languages?: string[]; 18 | schema?: Schema; 19 | protocolConfiguration?: ProtocolConfiguration; 20 | storageConfiguration?: StorageConfiguration; 21 | }; 22 | 23 | export class Configuration { 24 | languages: string[]; 25 | schema: Schema; 26 | protocolConfiguration: ProtocolConfiguration; 27 | storageConfiguration: StorageConfiguration; 28 | 29 | constructor(setup: ConfigurationSetup = {} as ConfigurationSetup) { 30 | const { 31 | languages, 32 | schema, 33 | protocolConfiguration, 34 | storageConfiguration, 35 | } = setup; 36 | 37 | this.setLanguages(languages || ['en']); 38 | 39 | if (schema) { 40 | this.setSchema(schema); 41 | } 42 | 43 | if (protocolConfiguration) { 44 | this.setProtocolConfiguration(protocolConfiguration); 45 | } 46 | 47 | if (storageConfiguration) { 48 | this.setStorageConfiguration(storageConfiguration); 49 | } 50 | } 51 | 52 | setLanguages(languages: string[]): void { 53 | passOrThrow( 54 | isArray(languages, true), 55 | () => 'Configuration.setLanguages() expects a list of language iso codes', 56 | ); 57 | 58 | languages.map((language) => { 59 | passOrThrow( 60 | languageIsoCodeRegex.test(language), 61 | () => 62 | `Invalid language iso code '${language}' provided (Regex: /${LANGUAGE_ISO_CODE_PATTERN}/)`, 63 | ); 64 | }); 65 | 66 | passOrThrow( 67 | languages.length === _.uniq(languages).length, 68 | () => 'Language codes should be unique in the list', 69 | ); 70 | 71 | this.languages = languages; 72 | } 73 | 74 | getLanguages(): string[] { 75 | return this.languages; 76 | } 77 | 78 | getDefaultLanguage(): string { 79 | return this.getLanguages()[0]; 80 | } 81 | 82 | setSchema(schema: Schema): void { 83 | passOrThrow(isSchema(schema), () => 'Configuration expects a valid schema'); 84 | 85 | this.schema = schema; 86 | } 87 | 88 | getSchema(): Schema { 89 | passOrThrow(this.schema, () => 'Configuration is missing a valid schema'); 90 | 91 | return this.schema; 92 | } 93 | 94 | setProtocolConfiguration(protocolConfiguration: ProtocolConfiguration): void { 95 | passOrThrow( 96 | isProtocolConfiguration(protocolConfiguration), 97 | () => 'Configuration expects a valid protocolConfiguration', 98 | ); 99 | 100 | this.protocolConfiguration = protocolConfiguration; 101 | 102 | protocolConfiguration.getParentConfiguration = () => this; 103 | } 104 | 105 | getProtocolConfiguration(): ProtocolConfiguration { 106 | passOrThrow( 107 | this.protocolConfiguration, 108 | () => 'Configuration is missing a valid protocolConfiguration', 109 | ); 110 | 111 | return this.protocolConfiguration; 112 | } 113 | 114 | setStorageConfiguration(storageConfiguration: StorageConfiguration): void { 115 | passOrThrow( 116 | isStorageConfiguration(storageConfiguration), 117 | () => 'Configuration expects a valid storageConfiguration', 118 | ); 119 | 120 | this.storageConfiguration = storageConfiguration; 121 | 122 | storageConfiguration.getParentConfiguration = () => this; 123 | } 124 | 125 | getStorageConfiguration(): StorageConfiguration { 126 | passOrThrow( 127 | this.storageConfiguration, 128 | () => 'Configuration is missing a valid storageConfiguration', 129 | ); 130 | 131 | return this.storageConfiguration; 132 | } 133 | } 134 | 135 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 136 | export const isConfiguration = (obj: unknown): obj is Configuration => { 137 | return obj instanceof Configuration; 138 | }; 139 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['prettier', '@typescript-eslint', 'import'], 4 | extends: [ 5 | 'plugin:@typescript-eslint/recommended', 6 | 'eslint:recommended', 7 | 'plugin:import/errors', 8 | 'plugin:import/warnings', 9 | 'plugin:prettier/recommended', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2018, 13 | sourceType: 'module', 14 | }, 15 | settings: { 16 | 'import/resolver': { 17 | typescript: {}, 18 | }, 19 | }, 20 | env: { 21 | es6: true, 22 | node: true, 23 | browser: false, 24 | jest: true, 25 | }, 26 | rules: { 27 | 'no-var': 'error', 28 | 'prefer-const': 'error', 29 | 'prefer-template': 'error', 30 | 'no-shadow': 'error', 31 | 'no-shadow-restricted-names': 'error', 32 | 'no-undef': 'error', 33 | 'no-unused-vars': 'off', 34 | '@typescript-eslint/no-unused-vars': 'error', 35 | 'no-cond-assign': ['error', 'always'], 36 | 'no-console': 'warn', 37 | 'no-debugger': 'error', 38 | 'no-constant-condition': 'warn', 39 | 'no-dupe-keys': 'error', 40 | 'no-duplicate-case': 'error', 41 | 'no-empty': 'error', 42 | 'no-ex-assign': 'error', 43 | 'no-extra-boolean-cast': 0, 44 | 'no-extra-semi': 'error', 45 | 'no-func-assign': 'error', 46 | 'no-inner-declarations': 'error', 47 | 'no-invalid-regexp': 'error', 48 | 'no-irregular-whitespace': 'error', 49 | 'no-obj-calls': 'error', 50 | 'no-sparse-arrays': 'error', 51 | 'no-unreachable': 'error', 52 | 'use-isnan': 'error', 53 | 'block-scoped-var': 'error', 54 | 'consistent-return': 'error', 55 | curly: ['error', 'multi-line'], 56 | 'default-case': 'error', 57 | 'dot-notation': [ 58 | 'error', 59 | { 60 | allowKeywords: true, 61 | }, 62 | ], 63 | eqeqeq: 'error', 64 | 'guard-for-in': 'error', 65 | 'no-caller': 'error', 66 | 'no-else-return': 'error', 67 | 'no-eq-null': 'error', 68 | 'no-eval': 'error', 69 | 'no-extend-native': 'error', 70 | 'no-extra-bind': 'error', 71 | 'no-fallthrough': 'error', 72 | 'no-floating-decimal': 'error', 73 | 'no-implied-eval': 'error', 74 | 'no-lone-blocks': 'error', 75 | 'no-loop-func': 'error', 76 | 'no-multi-str': 'error', 77 | 'no-native-reassign': 'error', 78 | 'no-new': 'error', 79 | 'no-new-func': 'error', 80 | 'no-new-wrappers': 'error', 81 | 'no-octal': 'error', 82 | 'no-octal-escape': 'error', 83 | 'no-param-reassign': 'error', 84 | 'no-proto': 'error', 85 | 'no-redeclare': 'error', 86 | 'no-return-assign': 'error', 87 | 'no-script-url': 'error', 88 | 'no-self-compare': 'error', 89 | 'no-sequences': 'error', 90 | 'no-throw-literal': 'error', 91 | 'no-with': 'error', 92 | radix: 'error', 93 | 'vars-on-top': 'error', 94 | 'wrap-iife': ['error', 'any'], 95 | yoda: 'error', 96 | quotes: ['error', 'single', 'avoid-escape'], 97 | camelcase: [ 98 | 'error', 99 | { 100 | properties: 'never', 101 | }, 102 | ], 103 | 'comma-spacing': [ 104 | 'error', 105 | { 106 | before: false, 107 | after: true, 108 | }, 109 | ], 110 | 'comma-style': ['error', 'last'], 111 | 'eol-last': 'error', 112 | 'func-names': 'warn', 113 | 'key-spacing': [ 114 | 'error', 115 | { 116 | beforeColon: false, 117 | afterColon: true, 118 | }, 119 | ], 120 | 'new-cap': [ 121 | 0, 122 | { 123 | newIsCap: true, 124 | }, 125 | ], 126 | 'no-nested-ternary': 'error', 127 | 'no-new-object': 'error', 128 | 'no-spaced-func': 'error', 129 | 'no-trailing-spaces': 'error', 130 | 'no-mixed-spaces-and-tabs': 'error', 131 | 'no-extra-parens': ['error', 'functions'], 132 | 'no-underscore-dangle': 0, 133 | 'one-var': ['error', 'never'], 134 | 'require-jsdoc': 'off', 135 | 'require-atomic-updates': 0, 136 | '@typescript-eslint/explicit-function-return-type': 'off', 137 | 'import/prefer-default-export': 0, 138 | 'import/named': 'error', 139 | 'import/no-cycle': 0, 140 | 'import/no-unresolved': 'error', 141 | }, 142 | }; 143 | --------------------------------------------------------------------------------