From 717af1379f44f85ab9d721b18092b908aced66d0 Mon Sep 17 00:00:00 2001 From: Nicola Marcacci Rossi Date: Mon, 21 Aug 2023 14:54:47 +0200 Subject: [PATCH] break: Improve model --- src/client/queries.ts | 26 ++-- src/db/generate.ts | 37 +++-- src/generate/generate.ts | 60 ++++---- src/generate/utils.ts | 12 +- src/migrations/generate.ts | 166 +++++++++++----------- src/models.ts | 270 +++++++++++++++--------------------- src/permissions/check.ts | 4 +- src/permissions/generate.ts | 4 +- src/resolvers/filters.ts | 2 +- src/resolvers/mutations.ts | 19 +-- src/resolvers/node.ts | 12 +- src/resolvers/resolver.ts | 4 +- src/utils.ts | 215 ++++++++++++++++++++-------- tests/utils/models.ts | 13 +- 14 files changed, 460 insertions(+), 384 deletions(-) diff --git a/src/client/queries.ts b/src/client/queries.ts index 0463c12..ad25f55 100644 --- a/src/client/queries.ts +++ b/src/client/queries.ts @@ -1,21 +1,20 @@ import upperFirst from 'lodash/upperFirst'; +import { Field } from '..'; +import { Model, Models, Relation, ReverseRelation } from '../models'; import { actionableRelations, and, + getModelPlural, + getModelPluralField, isQueriableBy, isRelation, isSimpleField, isToOneRelation, isUpdatableBy, - isVisibleRelation, - Model, - Models, not, - Relation, - ReverseRelation, - VisibleRelationsByRole, -} from '../models'; -import { getModelPlural, getModelPluralField, summonByName, typeToField } from '../utils'; + summonByName, + typeToField, +} from '../utils'; export const getUpdateEntityQuery = ( model: Model, @@ -54,8 +53,8 @@ export const getEditEntityRelationsQuery = ( !!relations.length && `query ${upperFirst(action)}${model.name}Relations { ${relations - .map(({ name, type }) => { - const model = summonByName(models, type); + .map(({ name, typeName }) => { + const model = summonByName(models, typeName); let filters = ''; if (model.displayField) { @@ -206,6 +205,13 @@ export const getEntityListQuery = ( ${root ? '}' : ''} }`; +export type VisibleRelationsByRole = Record>; + +export const isVisibleRelation = (visibleRelationsByRole: VisibleRelationsByRole, modelName: string, role: string) => { + const whitelist = visibleRelationsByRole[role]?.[modelName]; + return ({ name }: Field) => (whitelist ? whitelist.includes(name) : true); +}; + export const getEntityQuery = ( models: Models, model: Model, diff --git a/src/db/generate.ts b/src/db/generate.ts index 43d020c..5733912 100644 --- a/src/db/generate.ts +++ b/src/db/generate.ts @@ -1,5 +1,5 @@ import CodeBlockWriter from 'code-block-writer'; -import { ModelField, RawModels, getModels, isEnumModel } from '..'; +import { ModelField, RawModels, get, getModels, isEnumModel, isRaw, not } from '..'; const PRIMITIVE_TYPES = { ID: 'string', @@ -29,14 +29,14 @@ export const generateDBModels = (rawModels: RawModels) => { for (const model of models) { // TODO: deprecate allowing to define foreignKey - const fields = model.fields.some((field) => field.foreignKey === 'id') + const fields = model.fields.some((field) => field.type === 'relation' && field.foreignKey === 'id') ? model.fields.filter((field) => field.name !== 'id') : model.fields; writer .write(`export type ${model.name} = `) .inlineBlock(() => { - for (const field of fields.filter(({ raw }) => !raw)) { + for (const field of fields.filter(not(isRaw))) { writer.write(`'${getFieldName(field)}': ${getFieldOutputType(field)}${field.nonNull ? '' : ' | null'},`).newLine(); } }) @@ -45,7 +45,7 @@ export const generateDBModels = (rawModels: RawModels) => { writer .write(`export type ${model.name}Initializer = `) .inlineBlock(() => { - for (const field of fields.filter(({ raw }) => !raw)) { + for (const field of fields.filter(not(isRaw))) { writer .write( `'${getFieldName(field)}'${field.nonNull && field.default === undefined ? '' : '?'}: ${getFieldInputType( @@ -60,7 +60,7 @@ export const generateDBModels = (rawModels: RawModels) => { writer .write(`export type ${model.name}Mutator = `) .inlineBlock(() => { - for (const field of fields.filter(({ raw }) => !raw)) { + for (const field of fields.filter(not(isRaw))) { writer.write(`'${getFieldName(field)}'?: ${getFieldInputType(field)}${field.nonNull ? '' : ' | null'},`).newLine(); } }) @@ -69,7 +69,7 @@ export const generateDBModels = (rawModels: RawModels) => { writer .write(`export type ${model.name}Seed = `) .inlineBlock(() => { - for (const field of fields.filter(({ raw }) => !raw)) { + for (const field of fields.filter(not(isRaw))) { const fieldName = getFieldName(field); writer .write( @@ -95,16 +95,23 @@ export const generateDBModels = (rawModels: RawModels) => { return writer.toString(); }; -const getFieldName = ({ relation, name, foreignKey }: ModelField) => { - return foreignKey || `${name}${relation ? 'Id' : ''}`; -}; - -const getFieldOutputType = ({ relation, type, list, json }: ModelField) => { - if (json || relation) { - return 'string'; +const getFieldName = (field: ModelField) => (field.type === 'relation' ? field.foreignKey || `${field.name}Id` : field.name); + +const getFieldOutputType = (field: ModelField) => { + switch (field.type) { + case 'json': + // JSON data is stored as string + return 'string'; + case 'relation': + // Relations are stored as ids + return 'string'; + case 'enum': + return field.typeName + field.list ? '[]' : ''; + case 'raw': + throw new Error(`Raw fields are not in the db.`); + default: + return get(PRIMITIVE_TYPES, field.type) + (field.list ? '[]' : ''); } - - return (PRIMITIVE_TYPES[type] || type) + (list ? '[]' : ''); }; const getFieldInputType = (field: ModelField, stringTypes: string[] = []) => { diff --git a/src/generate/generate.ts b/src/generate/generate.ts index 1963b5d..567ff7f 100644 --- a/src/generate/generate.ts +++ b/src/generate/generate.ts @@ -1,17 +1,18 @@ import { buildASTSchema, DefinitionNode, DocumentNode, GraphQLSchema, print } from 'graphql'; import flatMap from 'lodash/flatMap'; +import { RawModels } from '../models'; import { - Field, + getModelPluralField, + getModels, isEnumModel, - isJsonObjectModel, isQueriableField, isRawEnumModel, isRawObjectModel, + isRelation, isScalarModel, - RawModels, -} from '../models'; -import { getModelPluralField, getModels, typeToField } from '../utils'; -import { document, enm, input, object, scalar } from './utils'; + typeToField, +} from '../utils'; +import { document, enm, Field, input, object, scalar } from './utils'; export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { const models = getModels(rawModels); @@ -26,17 +27,6 @@ export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { ...rawModels.filter(isRawEnumModel).map((model) => enm(model.name, model.values)), ...rawModels.filter(isScalarModel).map((model) => scalar(model.name)), ...rawModels.filter(isRawObjectModel).map((model) => object(model.name, model.fields)), - ...rawModels.filter(isJsonObjectModel).map((model) => object(model.name, model.fields)), - ...rawModels - .filter(isRawObjectModel) - .filter(({ rawFilters }) => rawFilters) - .map((model) => - input( - `${model.name}Where`, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- array gets filtered above to only include models with rawFilters - model.rawFilters!.map(({ name, type, list = false, nonNull = false }) => ({ name, type, list, nonNull })) - ) - ), ...flatMap( models.map((model) => { @@ -46,10 +36,9 @@ export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { [ ...model.fields.filter(isQueriableField).map((field) => ({ ...field, - args: [ - ...(field.args || []), - ...(hasRawFilters(rawModels, field.type) ? [{ name: 'where', type: `${field.type}Where` }] : []), - ], + type: + field.type === 'relation' || field.type === 'enum' || field.type === 'raw' ? field.typeName : field.type, + args: [...(field.args || [])], directives: field.directives, })), ...model.reverseRelations.map(({ name, field, model }) => ({ @@ -72,8 +61,13 @@ export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { ), input(`${model.name}Where`, [ ...model.fields - .filter(({ unique, filterable, relation }) => (unique || filterable) && !relation) - .map(({ name, type, defaultFilter }) => ({ name, type, list: true, default: defaultFilter })), + .filter(({ type, unique, filterable }) => (unique || filterable) && type !== 'relation') + .map(({ type, name, filterable }) => ({ + name, + type, + list: true, + default: typeof filterable === 'object' ? filterable.default : undefined, + })), ...flatMap( model.fields.filter(({ comparable }) => comparable), ({ name, type }) => [ @@ -84,10 +78,11 @@ export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { ] ), ...model.fields - .filter(({ filterable, relation }) => filterable && relation) - .map(({ name, type }) => ({ + .filter(isRelation) + .filter(({ filterable }) => filterable) + .map(({ name, typeName }) => ({ name, - type: `${type}Where`, + type: `${typeName}Where`, })), ]), input( @@ -110,10 +105,10 @@ export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { `Create${model.name}`, model.fields .filter(({ creatable }) => creatable) - .map(({ name, relation, type, nonNull, list, default: defaultValue }) => - relation + .map(({ name, nonNull, list, default: defaultValue, ...field }) => + field.type === 'relation' ? { name: `${name}Id`, type: 'ID', nonNull } - : { name, type, list, nonNull: nonNull && defaultValue === undefined } + : { name, type: field.type, list, nonNull: nonNull && defaultValue === undefined } ) ) ); @@ -125,8 +120,8 @@ export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { `Update${model.name}`, model.fields .filter(({ updatable }) => updatable) - .map(({ name, relation, type, list }) => - relation ? { name: `${name}Id`, type: 'ID' } : { name, type, list } + .map(({ name, type, list }) => + type === 'relation' ? { name: `${name}Id`, type: 'ID' } : { name, type, list } ) ) ); @@ -265,9 +260,6 @@ export const printSchema = (schema: GraphQLSchema): string => .map((s) => `${s}\n`) .join('\n'); -const hasRawFilters = (models: RawModels, type: string) => - models.filter(isRawObjectModel).some(({ name, rawFilters }) => name === type && !!rawFilters); - export const printSchemaFromDocument = (document: DocumentNode) => printSchema(buildASTSchema(document)); export const printSchemaFromModels = (models: RawModels) => printSchema(buildASTSchema(generate(models))); diff --git a/src/generate/utils.ts b/src/generate/utils.ts index 69bea04..0927e59 100644 --- a/src/generate/utils.ts +++ b/src/generate/utils.ts @@ -22,9 +22,19 @@ import { ValueNode, } from 'graphql'; import { DateTime } from 'luxon'; -import { Field } from '../models'; import { Directive, Enum, Value, Values } from '../values'; +export type Field = { + name: string; + type: string; + description?: string; + list?: boolean; + nonNull?: boolean; + default?: Value; + args?: Field[]; + directives?: Directive[]; +}; + export type DirectiveLocation = | 'ARGUMENT_DEFINITION' | 'INPUT_FIELD_DEFINITION' diff --git a/src/migrations/generate.ts b/src/migrations/generate.ts index 10dd6f0..07e5b54 100644 --- a/src/migrations/generate.ts +++ b/src/migrations/generate.ts @@ -4,8 +4,8 @@ import { SchemaInspector } from 'knex-schema-inspector'; import { Column } from 'knex-schema-inspector/dist/types/column'; import { SchemaInspector as SchemaInspectorType } from 'knex-schema-inspector/dist/types/schema-inspector'; import lowerFirst from 'lodash/lowerFirst'; -import { EnumModel, isEnumModel, Model, ModelField, Models, RawModels } from '../models'; -import { get, getModels, summonByName, typeToField } from '../utils'; +import { EnumModel, Model, ModelField, Models, RawModels } from '../models'; +import { get, getModels, isEnumModel, summonByName, typeToField } from '../utils'; import { Value } from '../values'; type Callbacks = (() => void)[]; @@ -130,20 +130,23 @@ export class MigrationGenerator { this.createFields( model, model.fields.filter( - ({ name, relation, raw, foreignKey }) => - !raw && !this.columns[model.name].some((col) => col.name === (foreignKey || (relation ? `${name}Id` : name))) + ({ name, ...field }) => + field.type !== 'raw' && + !this.columns[model.name].some( + (col) => col.name === (field.type === 'relation' ? field.foreignKey || `${name}Id` : name) + ) ), up, down ); // Update fields - const existingFields = model.fields.filter(({ name, relation, nonNull }) => { - const col = this.columns[model.name].find((col) => col.name === (relation ? `${name}Id` : name)); + const existingFields = model.fields.filter(({ name, type, nonNull }) => { + const col = this.columns[model.name].find((col) => col.name === (type === 'relation' ? `${name}Id` : name)); if (!col) { return false; } - return !model.nonStrict && !nonNull && !col.is_nullable; + return !nonNull && !col.is_nullable; }); this.updateFields(model, existingFields, up, down); } @@ -174,8 +177,8 @@ export class MigrationGenerator { writer.writeLine(`deleted: row.deleted,`); } - for (const { name, relation } of model.fields.filter(({ updatable }) => updatable)) { - const col = relation ? `${name}Id` : name; + for (const { name, type } of model.fields.filter(({ updatable }) => updatable)) { + const col = type === 'relation' ? `${name}Id` : name; writer.writeLine(`${col}: row.${col},`); } @@ -194,21 +197,25 @@ export class MigrationGenerator { } else { const revisionTable = `${model.name}Revision`; const missingRevisionFields = model.fields.filter( - ({ name, relation, raw, foreignKey, updatable }) => - !raw && + ({ name, updatable, ...field }) => + field.type !== 'raw' && updatable && - !this.columns[revisionTable].some((col) => col.name === (foreignKey || (relation ? `${name}Id` : name))) + !this.columns[revisionTable].some( + (col) => col.name === (field.type === 'relation' ? field.foreignKey || `${name}Id` : name) + ) ); this.createRevisionFields(model, missingRevisionFields, up, down); const revisionFieldsToRemove = model.fields.filter( - ({ name, updatable, foreignKey, relation, raw, generated }) => + ({ name, updatable, generated, ...field }) => !generated && - !raw && + field.type !== 'raw' && !updatable && - foreignKey !== 'id' && - this.columns[revisionTable].some((col) => col.name === (foreignKey || (relation ? `${name}Id` : name))) + !(field.type === 'relation' && field.foreignKey === 'id') && + this.columns[revisionTable].some( + (col) => col.name === (field.type === 'relation' ? field.foreignKey || `${name}Id` : name) + ) ); this.createRevisionFields(model, revisionFieldsToRemove, down, up); } @@ -269,8 +276,8 @@ export class MigrationGenerator { for (const field of fields) { this.alterTable(model.name, () => { this.renameColumn( - field.relation ? `${field.oldName}Id` : get(field, 'oldName'), - field.relation ? `${field.name}Id` : field.name + field.type === 'relation' ? `${field.oldName}Id` : get(field, 'oldName'), + field.type === 'relation' ? `${field.name}Id` : field.name ); }); } @@ -280,8 +287,8 @@ export class MigrationGenerator { for (const field of fields) { this.alterTable(model.name, () => { this.renameColumn( - field.relation ? `${field.name}Id` : field.name, - field.relation ? `${field.oldName}Id` : get(field, 'oldName') + field.type === 'relation' ? `${field.name}Id` : field.name, + field.type === 'relation' ? `${field.oldName}Id` : get(field, 'oldName') ); }); } @@ -289,9 +296,8 @@ export class MigrationGenerator { for (const field of fields) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - summonByName(this.columns[model.name]!, field.relation ? `${field.oldName!}Id` : field.oldName!).name = field.relation - ? `${field.name}Id` - : field.name; + summonByName(this.columns[model.name]!, field.type === 'relation' ? `${field.oldName!}Id` : field.oldName!).name = + field.type === 'relation' ? `${field.name}Id` : field.name; } } @@ -337,8 +343,8 @@ export class MigrationGenerator { down.push(() => { this.alterTable(model.name, () => { - for (const { relation, name } of fields) { - this.dropColumn(relation ? `${name}Id` : name); + for (const { type, name } of fields) { + this.dropColumn(type === 'relation' ? `${name}Id` : name); } }); }); @@ -363,7 +369,7 @@ export class MigrationGenerator { this.column( field, { alter: true }, - summonByName(this.columns[model.name], field.relation ? `${field.name}Id` : field.name) + summonByName(this.columns[model.name], field.type === 'relation' ? `${field.name}Id` : field.name) ); } }); @@ -389,7 +395,7 @@ export class MigrationGenerator { this.column( field, { alter: true }, - summonByName(this.columns[model.name], field.relation ? `${field.name}Id` : field.name) + summonByName(this.columns[model.name], field.type === 'relation' ? `${field.name}Id` : field.name) ); } }); @@ -405,9 +411,7 @@ export class MigrationGenerator { writer.writeLine(`table.uuid('id').notNullable().primary();`); writer.writeLine(`table.uuid('${typeToField(model.name)}Id').notNullable();`); writer.write(`table.uuid('createdById')`); - if (!model.nonStrict) { - writer.write('.notNullable()'); - } + writer.write('.notNullable()'); writer.write(';').newLine(); writer.writeLine(`table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now(0));`); if (model.deletable) { @@ -435,8 +439,8 @@ export class MigrationGenerator { this.writer .write(`await knex('${model.name}Revision').update(`) .inlineBlock(() => { - for (const { name, relation } of missingRevisionFields) { - const col = relation ? `${name}Id` : name; + for (const { name, type } of missingRevisionFields) { + const col = type === 'relation' ? `${name}Id` : name; this.writer .write( `${col}: knex.raw('(select "${col}" from "${model.name}" where "${model.name}".id = "${ @@ -463,7 +467,7 @@ export class MigrationGenerator { down.push(() => { this.alterTable(revisionTable, () => { for (const field of missingRevisionFields) { - this.dropColumn(field.relation ? `${field.name}Id` : field.name); + this.dropColumn(field.type === 'relation' ? `${field.name}Id` : field.name); } }); }); @@ -538,7 +542,7 @@ export class MigrationGenerator { } private column( - { name, relation, type, primary, list, ...field }: ModelField, + { name, primary, list, ...field }: ModelField, { setUnique = true, setNonNull = true, alter = false, foreign = true, setDefault = true } = {}, toColumn?: Column ) { @@ -574,60 +578,58 @@ export class MigrationGenerator { } this.writer.write(';').newLine(); }; - if (relation) { - col(`table.uuid('${name}Id')`); - if (foreign && !alter) { - this.writer.writeLine(`table.foreign('${name}Id').references('id').inTable('${type}');`); - } - } else if (this.rawModels.some((m) => m.name === type && m.type === 'enum')) { - list - ? this.writer.write(`table.specificType('${name}', '"${typeToField(type)}"[]');`) - : this.writer + switch (field.type) { + case 'Boolean': + col(`table.boolean('${name}')`); + break; + case 'Int': + col(`table.integer('${name}')`); + break; + case 'Float': + if (field.double) { + col(`table.double('${name}')`); + } else { + col(`table.decimal('${name}', ${get(field, 'precision')}, ${get(field, 'scale')})`); + } + break; + case 'String': + if (field.large) { + col(`table.text('${name}')`); + } else { + col(`table.string('${name}', ${field.maxLength})`); + } + break; + case 'DateTime': + col(`table.timestamp('${name}')`); + break; + case 'ID': + col(`table.uuid('${name}')`); + break; + case 'Upload': + break; + case 'relation': + col(`table.uuid('${name}Id')`); + if (foreign && !alter) { + this.writer.writeLine(`table.foreign('${name}Id').references('id').inTable('${field.typeName}');`); + } + break; + case 'enum': + if (list) { + this.writer.write(`table.specificType('${name}', '"${typeToField(field.type)}"[]');`); + } else { + this.writer .write(`table.enum('${name}', null as any, `) .inlineBlock(() => { this.writer.writeLine(`useNative: true,`); this.writer.writeLine(`existingType: true,`); - this.writer.writeLine(`enumName: '${typeToField(type)}',`); + this.writer.writeLine(`enumName: '${typeToField(field.type)}',`); }) .write(')'); - col(); - } else { - switch (type) { - case 'Boolean': - col(`table.boolean('${name}')`); - break; - case 'Int': - col(`table.integer('${name}')`); - break; - case 'Float': - if (field.double) { - col(`table.double('${name}')`); - } else { - col(`table.decimal('${name}', ${get(field, 'precision')}, ${get(field, 'scale')})`); - } - break; - case 'String': - if (field.large) { - col(`table.text('${name}')`); - } else { - col(`table.string('${name}', ${field.maxLength})`); - } - break; - case 'DateTime': - col(`table.timestamp('${name}')`); - break; - case 'ID': - if (field.maxLength) { - col(`table.string('${name}', ${get(field, 'maxLength')})`); - } else { - col(`table.uuid('${name}')`); - } - break; - case 'Upload': - break; - default: - throw new Error(`Unknown field type ${type}`); - } + } + col(); + break; + default: + throw new Error(`Unknown field type ${field.type}`); } } } diff --git a/src/models.ts b/src/models.ts index d22bef3..e7b3656 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,17 +1,12 @@ +import { DateTime } from 'luxon'; +import { Field } from '.'; import type { Context } from './context'; import type { OrderBy } from './resolvers/arguments'; -import type { Directive, Value } from './values'; +import type { Value } from './values'; export type RawModels = RawModel[]; -export type RawModel = - | ScalarModel - | EnumModel - | RawEnumModel - | InterfaceModel - | ObjectModel - | RawObjectModel - | JsonObjectModel; +export type RawModel = ScalarModel | EnumModel | RawEnumModel | InterfaceModel | ObjectModel | RawObjectModel; type BaseModel = { name: string; @@ -28,19 +23,11 @@ export type RawEnumModel = BaseModel & { type: 'raw-enum'; values: string[] }; export type InterfaceModel = BaseModel & { type: 'interface'; fields: ModelField[] }; export type RawObjectModel = BaseModel & { - type: 'raw-object'; - fields: ModelField[]; - rawFilters?: { name: string; type: string; list?: boolean; nonNull?: boolean }[]; -}; - -export type JsonObjectModel = BaseModel & { - type: 'json-object'; - json: true; - fields: Pick[]; + type: 'raw'; + fields: RawObjectField[]; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- data is derived from the models -export type Entity = Record; +export type Entity = Record & { createdAt?: DateTime; deletedAt?: DateTime }; export type Action = 'create' | 'update' | 'delete' | 'restore'; @@ -55,13 +42,13 @@ export type MutationHook = ( export type ObjectModel = BaseModel & { type: 'object'; interfaces?: string[]; - // createdAt, createdBy, updatedAt, updatedBy can be null - nonStrict?: boolean; queriable?: boolean; listQueriable?: boolean; - creatable?: boolean; - updatable?: boolean; - deletable?: boolean; + creatable?: boolean | { createdBy?: Partial; createdAt?: Partial }; + updatable?: boolean | { updatedBy?: Partial; updatedAt?: Partial }; + deletable?: + | boolean + | { deleted?: Partial; deletedBy?: Partial; deletedAt?: Partial }; displayField?: string; defaultOrderBy?: OrderBy; fields: ModelField[]; @@ -71,136 +58,109 @@ export type ObjectModel = BaseModel & { oldName?: string; }; -export type InputObject = { - name: string; - type: string; - nonNull?: boolean; -}; - -export const isObjectModel = (model: RawModel): model is ObjectModel => model.type === 'object'; - -export const isEnumModel = (model: RawModel): model is EnumModel => model.type === 'enum'; - -export const isRawEnumModel = (model: RawModel): model is RawEnumModel => model.type === 'raw-enum'; - -export const isScalarModel = (model: RawModel): model is ScalarModel => model.type === 'scalar'; - -export const isRawObjectModel = (model: RawModel): model is RawObjectModel => model.type === 'raw-object'; - -export const isJsonObjectModel = (model: RawModel): model is RawObjectModel => model.type === 'json-object'; - -export const isEnumList = (models: RawModels, field: ModelField) => - field?.list === true && models.find(({ name }) => name === field.type)?.type === 'enum'; - -export const and = - (...predicates: ((field: ModelField) => boolean)[]) => - (field: ModelField) => - predicates.every((predicate) => predicate(field)); - -export const not = (predicate: (field: ModelField) => boolean) => (field: ModelField) => !predicate(field); - -export const isRelation = ({ relation }: ModelField) => !!relation; - -export type VisibleRelationsByRole = Record>; - -export const isVisibleRelation = (visibleRelationsByRole: VisibleRelationsByRole, modelName: string, role: string) => { - const whitelist = visibleRelationsByRole[role]?.[modelName]; - return ({ name }: Field) => (whitelist ? whitelist.includes(name) : true); -}; - -export const isToOneRelation = ({ toOne }: ModelField) => !!toOne; - -export const isQueriableField = ({ queriable }: ModelField) => queriable !== false; - -export const isRaw = ({ raw }: ModelField) => !!raw; - -export const isVisible = ({ hidden }: ModelField) => hidden !== true; - -export const isSimpleField = and(not(isRelation), not(isRaw)); - -export const isUpdatable = ({ updatable }: ModelField) => !!updatable; - -export const isCreatable = ({ creatable }: ModelField) => !!creatable; - -export const isQueriableBy = (role: string) => (field: ModelField) => - isQueriableField(field) && (!field.queriableBy || field.queriableBy.includes(role)); - -export const isUpdatableBy = (role: string) => (field: ModelField) => - isUpdatable(field) && (!field.updatableBy || field.updatableBy.includes(role)); - -export const isCreatableBy = (role: string) => (field: ModelField) => - isCreatable(field) && (!field.creatableBy || field.creatableBy.includes(role)); - -export const actionableRelations = (model: Model, action: 'create' | 'update' | 'filter') => - model.fields.filter( - ({ relation, ...field }) => - relation && - field[`${action === 'filter' ? action : action.slice(0, -1)}able` as 'filterable' | 'creatable' | 'updatable'] - ); - -export type Field = { - name: string; - type: string; - default?: Value; - list?: boolean; - nonNull?: boolean; - args?: Field[]; - directives?: Directive[]; -}; - -export type ModelField = Field & { - primary?: boolean; - unique?: boolean; - filterable?: boolean; - defaultFilter?: Value; - searchable?: boolean; - possibleValues?: Value[]; - orderable?: boolean; - comparable?: boolean; - relation?: boolean; - onDelete?: 'cascade' | 'set-null'; - reverse?: string; - toOne?: boolean; - foreignKey?: string; - queriable?: false; - queriableBy?: string[]; - creatable?: boolean; - creatableBy?: string[]; - updatable?: boolean; - updatableBy?: string[]; - generated?: boolean; - raw?: boolean; - json?: boolean; - dateTimeType?: 'year' | 'date' | 'datetime' | 'year_and_month'; - stringType?: 'email' | 'url' | 'phone'; - floatType?: 'currency' | 'percentage'; +type BaseNumberType = { unit?: 'million'; - intType?: 'currency'; min?: number; max?: number; - // The tooltip is "hidden" behind an icon in the admin forms - tooltip?: string; - // The description is always visible below the inputs in the admin forms - description?: string; - large?: true; - maxLength?: number; - double?: boolean; - precision?: number; - scale?: number; - defaultValue?: string | number | ReadonlyArray | undefined; - endOfDay?: boolean; - obfuscate?: true; - // If true the field must be filled within forms but can be null in the database - required?: boolean; - indent?: boolean; - // If true the field is hidden in the admin interface - hidden?: boolean; - - // temporary fields for the generation of migrations - deleted?: true; - oldName?: string; }; +type BaseField = Omit; + +type PrimitiveField = + | { type: 'ID' } + | { type: 'Boolean' } + | { + type: 'String'; + stringType?: 'email' | 'url' | 'phone'; + large?: true; + maxLength?: number; + } + | { + type: 'DateTime'; + dateTimeType?: 'year' | 'date' | 'datetime' | 'year_and_month'; + endOfDay?: boolean; + } + | ({ + type: 'Int'; + intType?: 'currency'; + } & BaseNumberType) + | ({ + type: 'Float'; + floatType?: 'currency' | 'percentage'; + double?: boolean; + precision?: number; + scale?: number; + } & BaseNumberType) + | { type: 'Upload' }; + +type RawObjectField = BaseField & PrimitiveField; + +export type ModelField = BaseField & + ( + | PrimitiveField + | { type: 'json'; typeName: string } + | { type: 'enum'; typeName: string; possibleValues?: Value[] } + | { type: 'raw'; typeName: string } + | { + type: 'relation'; + typeName: string; + toOne?: boolean; + reverse?: string; + foreignKey?: string; + onDelete?: 'cascade' | 'set-null'; + } + ) & { + primary?: boolean; + unique?: boolean; + filterable?: + | boolean + | { + default?: Value; + }; + searchable?: boolean; + orderable?: boolean; + comparable?: boolean; + queriable?: + | boolean + | { + roles?: string[]; + }; + creatable?: + | boolean + | { + roles?: string[]; + }; + updatable?: + | boolean + | { + roles?: string[]; + }; + generated?: boolean; + // The tooltip is "hidden" behind an icon in the admin forms + tooltip?: string; + defaultValue?: string | number | ReadonlyArray | undefined; + // If true the field must be filled within forms but can be null in the database + required?: boolean; + indent?: boolean; + // If true the field is hidden in the admin interface + hidden?: boolean; + + // temporary fields for the generation of migrations + deleted?: true; + oldName?: string; + }; + +export type IDField = Extract; +export type BooleanField = Extract; +export type StringField = Extract; +export type DateTimeField = Extract; +export type IntField = Extract; +export type FloatField = Extract; +export type JsonField = Extract; +export type EnumField = Extract; +export type RawField = Extract; +export type RelationField = Extract; + export type Models = Model[]; export type Model = ObjectModel & { @@ -212,17 +172,13 @@ export type Model = ObjectModel & { }; export type Relation = { - field: ModelField; + field: RelationField; model: Model; reverseRelation: ReverseRelation; }; -export type ReverseRelation = { - name: string; - type: string; - foreignKey: string; - toOne: boolean; +export type ReverseRelation = RelationField & { model: Model; - field: ModelField; + field: RelationField; fieldModel: Model; }; diff --git a/src/permissions/check.ts b/src/permissions/check.ts index d047a93..a641e73 100644 --- a/src/permissions/check.ts +++ b/src/permissions/check.ts @@ -3,7 +3,7 @@ import { FullContext } from '../context'; import { NotFoundError, PermissionError } from '../errors'; import { Model } from '../models'; import { AliasGenerator, hash, ors } from '../resolvers/utils'; -import { get, getModelPlural, summonByName } from '../utils'; +import { get, getModelPlural, isRelation, summonByName } from '../utils'; import { BasicValue } from '../values'; import { PermissionAction, PermissionLink, PermissionStack } from './generate'; @@ -139,7 +139,7 @@ export const checkCanWrite = async ( let linked = false; for (const field of model.fields - .filter(({ relation }) => relation) + .filter(isRelation) .filter((field) => field.generated || (action === 'CREATE' ? field.creatable : field.updatable))) { const foreignKey = field.foreignKey || `${field.name}Id`; const foreignId = data[foreignKey] as string; diff --git a/src/permissions/generate.ts b/src/permissions/generate.ts index 3b74a4b..a1b251c 100644 --- a/src/permissions/generate.ts +++ b/src/permissions/generate.ts @@ -1,5 +1,5 @@ import { Models } from '../models'; -import { summonByName } from '../utils'; +import { isRelation, summonByName } from '../utils'; export type PermissionAction = 'READ' | 'CREATE' | 'UPDATE' | 'DELETE' | 'RESTORE' | 'LINK'; @@ -114,7 +114,7 @@ const addPermissions = (models: Models, permissions: RolePermissions, links: Per if (block.RELATIONS) { for (const [relation, subBlock] of Object.entries(block.RELATIONS)) { - const field = model.fields.find((field) => field.relation && field.name === relation); + const field = model.fields.filter(isRelation).find((field) => field.name === relation); let link: PermissionLink; if (field) { link = { diff --git a/src/resolvers/filters.ts b/src/resolvers/filters.ts index bcc1dfb..a0a62e1 100644 --- a/src/resolvers/filters.ts +++ b/src/resolvers/filters.ts @@ -85,7 +85,7 @@ const applyWhere = (node: WhereNode, where: Where, ops: Ops, const field = summonByName(node.model.fields, key); const fullKey = `${node.shortTableAlias}.${key}`; - if (field.relation) { + if (field.type === 'relation') { const relation = get(node.model.relationsByName, field.name); const tableAlias = `${node.model.name}__W__${key}`; const subNode: WhereNode = { diff --git a/src/resolvers/mutations.ts b/src/resolvers/mutations.ts index 94484b7..d85bf26 100644 --- a/src/resolvers/mutations.ts +++ b/src/resolvers/mutations.ts @@ -1,10 +1,11 @@ import { GraphQLResolveInfo } from 'graphql'; +import { DateTime } from 'luxon'; import { v4 as uuid } from 'uuid'; import { Context, FullContext } from '../context'; import { ForbiddenError, GraphQLError } from '../errors'; -import { Entity, Model, ModelField, isEnumList } from '../models'; +import { Entity, Model, ModelField } from '../models'; import { applyPermissions, checkCanWrite, getEntityToMutate } from '../permissions/check'; -import { get, it, summonByName, typeToField } from '../utils'; +import { get, isEnumList, it, summonByName, typeToField } from '../utils'; import { resolve } from './resolver'; import { AliasGenerator } from './utils'; @@ -113,10 +114,10 @@ const del = async (model: Model, { where, dryRun }: { where: any; dryRun: boolea if (!(currentModel.name in toDelete)) { toDelete[currentModel.name] = {}; } - if (entity.id in toDelete[currentModel.name]) { + if ((entity.id as string) in toDelete[currentModel.name]) { return; } - toDelete[currentModel.name][entity.id] = entity[currentModel.displayField || 'id'] || entity.id; + toDelete[currentModel.name][entity.id as string] = (entity[currentModel.displayField || 'id'] || entity.id) as string; if (!dryRun) { const normalizedInput = { deleted: true, deletedAt: ctx.now, deletedById: ctx.user.id }; @@ -275,8 +276,8 @@ const createRevision = async (model: Model, data: Entity, ctx: Context) => { revisionData.deleted = data.deleted || false; } - for (const { name, relation, nonNull, ...field } of model.fields.filter(({ updatable }) => updatable)) { - const col = relation ? `${name}Id` : name; + for (const { type, name, nonNull, ...field } of model.fields.filter(({ updatable }) => updatable)) { + const col = type === 'relation' ? `${name}Id` : name; if (nonNull && (!(col in data) || col === undefined || col === null)) { revisionData[col] = get(field, 'default'); } else { @@ -301,16 +302,16 @@ const sanitize = (ctx: FullContext, model: Model, data: Entity) => { } if (isEndOfDay(field) && data[key]) { - data[key] = data[key].endOf('day'); + data[key] = (data[key] as DateTime).endOf('day'); continue; } if (isEnumList(ctx.rawModels, field) && Array.isArray(data[key])) { - data[key] = `{${data[key].join(',')}}`; + data[key] = `{${(data[key] as string[]).join(',')}}`; continue; } } }; const isEndOfDay = (field?: ModelField) => - field?.endOfDay === true && field?.dateTimeType === 'date' && field?.type === 'DateTime'; + field.type === 'DateTime' && field?.endOfDay === true && field?.dateTimeType === 'date' && field?.type === 'DateTime'; diff --git a/src/resolvers/node.ts b/src/resolvers/node.ts index e1b6e3f..48f85ba 100644 --- a/src/resolvers/node.ts +++ b/src/resolvers/node.ts @@ -8,8 +8,8 @@ import type { } from 'graphql'; import { FullContext } from '../context'; -import { isJsonObjectModel, Model } from '../models'; -import { get, summonByKey, summonByName } from '../utils'; +import { Model } from '../models'; +import { get, isRawObjectModel, summonByKey, summonByName } from '../utils'; import { getFragmentTypeName, getNameOrAlias, @@ -113,7 +113,7 @@ export const getSimpleFields = (node: ResolverNode) => { return true; } - return node.model.fields.some(({ json, name }) => json && name === selection.name.value); + return node.model.fields.some(({ type, name }) => type === 'json' && name === selection.name.value); }); }; @@ -150,13 +150,13 @@ export const getJoins = (node: ResolverNode, toMany: boolean) => { const typeName = getTypeName(fieldDefinition.type); - if (isJsonObjectModel(summonByName(ctx.rawModels, typeName))) { + if (isRawObjectModel(summonByName(ctx.rawModels, typeName))) { continue; } const baseModel = summonByName(ctx.models, baseTypeDefinition.name.value); - let foreignKey; + let foreignKey: string | undefined; if (toMany) { const reverseRelation = baseModel.reverseRelationsByName[fieldName]; if (!reverseRelation) { @@ -165,7 +165,7 @@ export const getJoins = (node: ResolverNode, toMany: boolean) => { foreignKey = reverseRelation.foreignKey; } else { const modelField = baseModel.fieldsByName[fieldName]; - if (!modelField || modelField.raw) { + if (modelField?.type !== 'relation') { continue; } foreignKey = modelField.foreignKey; diff --git a/src/resolvers/resolver.ts b/src/resolvers/resolver.ts index 6a33274..1c5f3f3 100644 --- a/src/resolvers/resolver.ts +++ b/src/resolvers/resolver.ts @@ -115,11 +115,11 @@ const applySelects = (node: ResolverNode, query: Knex.QueryBuilder, joins: Joins .filter((n) => { const field = node.model.fields.find(({ name }) => name === n.name.value); - if (!field || field.relation || field.raw) { + if (!field || field.type === 'relation' || field.type === 'raw') { return false; } - if (field.queriableBy && !field.queriableBy.includes(node.ctx.user.role)) { + if (typeof field.queriable === 'object' && !field.queriable.roles?.includes(node.ctx.user.role)) { throw new PermissionError( 'READ', `${node.model.name}'s field "${field.name}"`, diff --git a/src/utils.ts b/src/utils.ts index 5cbe3f5..d0d1eeb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,7 +4,24 @@ import camelCase from 'lodash/camelCase'; import lodashGet from 'lodash/get'; import kebabCase from 'lodash/kebabCase'; import startCase from 'lodash/startCase'; -import { Model, Models, ObjectModel, RawModels, Relation, ReverseRelation, isObjectModel } from './models'; +import { + BooleanField, + DateTimeField, + EnumModel, + Model, + ModelField, + Models, + ObjectModel, + RawEnumModel, + RawField, + RawModel, + RawModels, + RawObjectModel, + Relation, + RelationField, + ReverseRelation, + ScalarModel, +} from './models'; const isNotFalsy = (v: T | null | undefined | false): v is T => typeof v !== 'undefined' && v !== null && v !== false; @@ -26,6 +43,59 @@ export const getModelLabel = (model: Model) => getLabel(model.name); export const getLabel = (s: string) => startCase(camelCase(s)); +export const isObjectModel = (model: RawModel): model is ObjectModel => model.type === 'object'; + +export const isEnumModel = (model: RawModel): model is EnumModel => model.type === 'enum'; + +export const isRawEnumModel = (model: RawModel): model is RawEnumModel => model.type === 'raw-enum'; + +export const isScalarModel = (model: RawModel): model is ScalarModel => model.type === 'scalar'; + +export const isRawObjectModel = (model: RawModel): model is RawObjectModel => model.type === 'raw'; + +export const isEnumList = (models: RawModels, field: ModelField) => + field?.list === true && models.find(({ name }) => name === field.type)?.type === 'enum'; + +export const and = + (...predicates: ((field: ModelField) => boolean)[]) => + (field: ModelField) => + predicates.every((predicate) => predicate(field)); + +export const not = (predicate: (field: ModelField) => boolean) => (field: ModelField) => !predicate(field); + +export const isRelation = (field: ModelField): field is RelationField => field.type === 'relation'; + +export const isToOneRelation = (field: ModelField): field is RelationField => isRelation(field) && !!field.toOne; + +export const isQueriableField = ({ queriable }: ModelField) => queriable !== false; + +export const isRaw = (field: ModelField): field is RawField => field.type === 'raw'; + +export const isVisible = ({ hidden }: ModelField) => hidden !== true; + +export const isSimpleField = and(not(isRelation), not(isRaw)); + +export const isUpdatable = ({ updatable }: ModelField) => !!updatable; + +export const isCreatable = ({ creatable }: ModelField) => !!creatable; + +export const isQueriableBy = (role: string) => (field: ModelField) => + field.queriable !== false && (field.queriable == true || !field.queriable.roles || field.queriable.roles.includes(role)); + +export const isUpdatableBy = (role: string) => (field: ModelField) => + field.updatable && (field.updatable === true || !field.updatable.roles || field.updatable.roles.includes(role)); + +export const isCreatableBy = (role: string) => (field: ModelField) => + field.creatable && (field.creatable === true || !field.creatable.roles || field.creatable.roles.includes(role)); + +export const actionableRelations = (model: Model, action: 'create' | 'update' | 'filter') => + model.fields + .filter(isRelation) + .filter( + (field) => + field[`${action === 'filter' ? action : action.slice(0, -1)}able` as 'filterable' | 'creatable' | 'updatable'] + ); + export const getModels = (rawModels: RawModels): Models => { const models: Models = rawModels.filter(isObjectModel).map((model) => { const objectModel: Model = { @@ -35,60 +105,86 @@ export const getModels = (rawModels: RawModels): Models => { relationsByName: {}, reverseRelations: [], reverseRelationsByName: {}, - fields: [ - { name: 'id', type: 'ID', nonNull: true, unique: true, primary: true, generated: true }, - ...model.fields, - ...(model.creatable - ? [ - { name: 'createdAt', type: 'DateTime', nonNull: !model.nonStrict, orderable: true, generated: true }, - { - name: 'createdBy', - type: 'User', - relation: true, - nonNull: !model.nonStrict, - reverse: `created${getModelPlural(model)}`, - generated: true, - }, - ] - : []), - ...(model.updatable - ? [ - { name: 'updatedAt', type: 'DateTime', nonNull: !model.nonStrict, orderable: true, generated: true }, - { - name: 'updatedBy', - type: 'User', - relation: true, - nonNull: !model.nonStrict, - reverse: `updated${getModelPlural(model)}`, - generated: true, - }, - ] - : []), - ...(model.deletable - ? [ - { - name: 'deleted', - type: 'Boolean', - nonNull: true, - default: false, - filterable: true, - defaultFilter: false, - generated: true, - }, - { name: 'deletedAt', type: 'DateTime', orderable: true, generated: true }, - { - name: 'deletedBy', - type: 'User', - relation: true, - reverse: `deleted${getModelPlural(model)}`, - generated: true, - }, - ] - : []), - ].map(({ foreignKey, ...field }) => ({ + fields: ( + [ + { name: 'id', type: 'ID', nonNull: true, unique: true, primary: true, generated: true }, + ...model.fields, + ...(model.creatable + ? [ + { + name: 'createdAt', + type: 'DateTime', + + nonNull: true, + orderable: true, + generated: true, + ...(typeof model.creatable === 'object' && model.creatable.createdAt), + } satisfies DateTimeField, + { + name: 'createdBy', + type: 'relation', + typeName: 'User', + nonNull: true, + reverse: `created${getModelPlural(model)}`, + generated: true, + ...(typeof model.creatable === 'object' && model.creatable.createdBy), + } satisfies RelationField, + ] + : []), + ...(model.updatable + ? [ + { + name: 'updatedAt', + type: 'DateTime', + nonNull: true, + orderable: true, + generated: true, + ...(typeof model.updatable === 'object' && model.updatable.updatedAt), + } satisfies DateTimeField, + { + name: 'updatedBy', + type: 'relation', + typeName: 'User', + nonNull: true, + reverse: `updated${getModelPlural(model)}`, + generated: true, + ...(typeof model.updatable === 'object' && model.updatable.updatedBy), + } satisfies RelationField, + ] + : []), + ...(model.deletable + ? [ + { + name: 'deleted', + type: 'Boolean', + nonNull: true, + default: false, + filterable: { default: false }, + generated: true, + ...(typeof model.deletable === 'object' && model.deletable.deleted), + } satisfies BooleanField, + { + name: 'deletedAt', + type: 'DateTime', + orderable: true, + generated: true, + ...(typeof model.deletable === 'object' && model.deletable.deletedAt), + } satisfies DateTimeField, + { + name: 'deletedBy', + type: 'relation', + typeName: 'User', + reverse: `deleted${getModelPlural(model)}`, + generated: true, + ...(typeof model.deletable === 'object' && model.deletable.deletedBy), + } satisfies RelationField, + ] + : []), + ] satisfies ModelField[] + ).map((field: ModelField) => ({ ...field, - ...(field.relation && { - foreignKey: foreignKey || `${field.name}Id`, + ...(field.type === 'relation' && { + foreignKey: field.foreignKey || `${field.name}Id`, }), })), }; @@ -101,13 +197,18 @@ export const getModels = (rawModels: RawModels): Models => { }); for (const model of models) { - for (const field of model.fields.filter(({ relation }) => relation)) { - const fieldModel = summonByName(models, field.type); + for (const field of model.fields) { + if (field.type !== 'relation') { + continue; + } + + const fieldModel = summonByName(models, field.typeName); const reverseRelation: ReverseRelation = { + type: 'relation', name: field.reverse || (field.toOne ? typeToField(model.name) : getModelPluralField(model)), foreignKey: get(field, 'foreignKey'), - type: model.name, + typeName: model.name, toOne: !!field.toOne, fieldModel, field, diff --git a/tests/utils/models.ts b/tests/utils/models.ts index aaab1cf..d7d4bf2 100644 --- a/tests/utils/models.ts +++ b/tests/utils/models.ts @@ -16,7 +16,7 @@ export const rawModels: RawModels = [ { name: 'SomeRawObject', - type: 'raw-object', + type: 'raw', fields: [{ name: 'field', type: 'String' }], }, @@ -30,7 +30,8 @@ export const rawModels: RawModels = [ }, { name: 'role', - type: 'Role', + type: 'enum', + typeName: 'Role', }, ], }, @@ -47,11 +48,11 @@ export const rawModels: RawModels = [ orderable: true, }, { - type: 'AnotherObject', + type: 'relation', + typeName: 'AnotherObject', name: 'myself', toOne: true, reverse: 'self', - relation: true } ], }, @@ -73,8 +74,8 @@ export const rawModels: RawModels = [ }, { name: 'another', - type: 'AnotherObject', - relation: true, + type: 'relation', + typeName: 'AnotherObject', filterable: true, updatable: true, nonNull: true,