diff --git a/src/client/index.ts b/src/client/index.ts index 8621b87..8bd6833 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,5 +1,6 @@ // created from 'create-ts-index' export * from './gql'; +export * from './models'; export * from './mutations'; export * from './queries'; diff --git a/src/client/models.ts b/src/client/models.ts new file mode 100644 index 0000000..6fb03f6 --- /dev/null +++ b/src/client/models.ts @@ -0,0 +1 @@ +export type ClientEntity = Record; diff --git a/src/client/queries.ts b/src/client/queries.ts index 059d460..bc6f683 100644 --- a/src/client/queries.ts +++ b/src/client/queries.ts @@ -1,5 +1,5 @@ import upperFirst from 'lodash/upperFirst'; -import { Field } from '..'; +import { ModelField } from '..'; import { Model, Models, Relation, ReverseRelation } from '../models/models'; import { actionableRelations, @@ -53,8 +53,8 @@ export const getEditEntityRelationsQuery = ( !!relations.length && `query ${upperFirst(action)}${model.name}Relations { ${relations - .map(({ name, typeName }) => { - const model = summonByName(models, typeName); + .map(({ name, type }) => { + const model = summonByName(models, type); let filters = ''; if (model.displayField) { @@ -209,7 +209,7 @@ 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); + return ({ name }: ModelField) => (whitelist ? whitelist.includes(name) : true); }; export const getEntityQuery = ( @@ -224,7 +224,7 @@ export const getEntityQuery = ( ${model.fields.filter(and(isSimpleField, isQueriableBy(role))).map(({ name }) => name)} ${queryRelations( models, - model.fields.filter(and(isRelation, isVisibleRelation(visibleRelationsByRole, model.name, role))), + model.fields.filter(isRelation).filter(isVisibleRelation(visibleRelationsByRole, model.name, role)), role, typesWithSubRelations )} diff --git a/src/context.ts b/src/context.ts index f780b16..8803610 100644 --- a/src/context.ts +++ b/src/context.ts @@ -2,7 +2,8 @@ import { DocumentNode, GraphQLResolveInfo } from 'graphql'; import { IncomingMessage } from 'http'; import { Knex } from 'knex'; import { DateTime } from 'luxon'; -import { Entity, Models, MutationHook, RawModels } from './models/models'; +import { Models, RawModels } from './models/models'; +import { Entity, MutationHook } from './models/mutation-hook'; import { Permissions } from './permissions/generate'; import { AliasGenerator } from './resolvers/utils'; diff --git a/src/db/generate.ts b/src/db/generate.ts index 3448977..b1a65dc 100644 --- a/src/db/generate.ts +++ b/src/db/generate.ts @@ -29,7 +29,7 @@ export const generateDBModels = (rawModels: RawModels) => { for (const model of models) { // TODO: deprecate allowing to define foreignKey - const fields = model.fields.some((field) => field.type === 'relation' && field.foreignKey === 'id') + const fields = model.fields.some((field) => field.kind === 'relation' && field.foreignKey === 'id') ? model.fields.filter((field) => field.name !== 'id') : model.fields; @@ -37,7 +37,7 @@ export const generateDBModels = (rawModels: RawModels) => { .write(`export type ${model.name} = `) .inlineBlock(() => { for (const field of fields.filter(not(isRaw))) { - writer.write(`'${getFieldName(field)}': ${getFieldOutputType(field)}${field.nonNull ? '' : ' | null'},`).newLine(); + writer.write(`'${getFieldName(field)}': ${getFieldType(field)}${field.nonNull ? '' : ' | null'},`).newLine(); } }) .blankLine(); @@ -48,9 +48,9 @@ export const generateDBModels = (rawModels: RawModels) => { for (const field of fields.filter(not(isRaw))) { writer .write( - `'${getFieldName(field)}'${field.nonNull && field.default === undefined ? '' : '?'}: ${getFieldInputType( + `'${getFieldName(field)}'${field.nonNull && field.defaultValue === undefined ? '' : '?'}: ${getFieldType( field - )}${field.nonNull ? '' : ' | null'},` + )}${field.list ? ' | string' : ''}${field.nonNull ? '' : ' | null'},` ) .newLine(); } @@ -61,7 +61,13 @@ export const generateDBModels = (rawModels: RawModels) => { .write(`export type ${model.name}Mutator = `) .inlineBlock(() => { for (const field of fields.filter(not(isRaw))) { - writer.write(`'${getFieldName(field)}'?: ${getFieldInputType(field)}${field.nonNull ? '' : ' | null'},`).newLine(); + writer + .write( + `'${getFieldName(field)}'?: ${getFieldType(field)}${field.list ? ' | string' : ''}${ + field.nonNull ? '' : ' | null' + },` + ) + .newLine(); } }) .blankLine(); @@ -74,11 +80,10 @@ export const generateDBModels = (rawModels: RawModels) => { writer .write( `'${getFieldName(field)}'${ - field.nonNull && field.default === undefined && !OPTIONAL_SEED_FIELDS.includes(fieldName) ? '' : '?' - }: ${getFieldInputType( - field, - rawModels.filter(isEnumModel).map(({ name }) => name) - )}${field.list ? ' | string' : ''}${field.nonNull ? '' : ' | null'},` + field.nonNull && field.defaultValue === undefined && !OPTIONAL_SEED_FIELDS.includes(fieldName) ? '' : '?' + }: ${field.kind === 'enum' ? (field.list ? 'string[]' : 'string') : getFieldType(field)}${ + field.list ? ' | string' : '' + }${field.nonNull ? '' : ' | null'},` ) .newLine(); } @@ -95,10 +100,11 @@ export const generateDBModels = (rawModels: RawModels) => { return writer.toString(); }; -const getFieldName = (field: ModelField) => (field.type === 'relation' ? field.foreignKey || `${field.name}Id` : field.name); +const getFieldName = (field: ModelField) => (field.kind === 'relation' ? field.foreignKey || `${field.name}Id` : field.name); -const getFieldOutputType = (field: ModelField) => { - switch (field.type) { +const getFieldType = (field: ModelField) => { + const kind = field.kind; + switch (kind) { case 'json': // JSON data is stored as string return 'string'; @@ -106,25 +112,17 @@ const getFieldOutputType = (field: ModelField) => { // Relations are stored as ids return 'string'; case 'enum': - return field.typeName + (field.list ? '[]' : ''); + return field.type + (field.list ? '[]' : ''); case 'raw': throw new Error(`Raw fields are not in the db.`); - default: + case 'primitive': + case undefined: return get(PRIMITIVE_TYPES, field.type) + (field.list ? '[]' : ''); - } -}; - -const getFieldInputType = (field: ModelField, stringTypes: string[] = []) => { - let outputType = getFieldOutputType(field); - - if (field.list || stringTypes.includes(field.type)) { - outputType += ' | string'; - if (field.list && stringTypes.includes(field.type)) { - outputType += ' | string[]'; + default: { + const exhaustiveCheck: never = kind; + throw new Error(exhaustiveCheck); } } - - return outputType; }; export const generateKnexTables = (rawModels: RawModels) => { diff --git a/src/migrations/generate.ts b/src/migrations/generate.ts index 6b6ae16..1c98034 100644 --- a/src/migrations/generate.ts +++ b/src/migrations/generate.ts @@ -131,9 +131,9 @@ export class MigrationGenerator { model, model.fields.filter( ({ name, ...field }) => - field.type !== 'raw' && + field.kind !== 'raw' && !this.columns[model.name].some( - (col) => col.name === (field.type === 'relation' ? field.foreignKey || `${name}Id` : name) + (col) => col.name === (field.kind === 'relation' ? field.foreignKey || `${name}Id` : name) ) ), up, @@ -141,8 +141,8 @@ export class MigrationGenerator { ); // Update fields - const existingFields = model.fields.filter(({ name, type, nonNull }) => { - const col = this.columns[model.name].find((col) => col.name === (type === 'relation' ? `${name}Id` : name)); + const existingFields = model.fields.filter(({ name, kind, nonNull }) => { + const col = this.columns[model.name].find((col) => col.name === (kind === 'relation' ? `${name}Id` : name)); if (!col) { return false; } @@ -177,8 +177,8 @@ export class MigrationGenerator { writer.writeLine(`deleted: row.deleted,`); } - for (const { name, type } of model.fields.filter(({ updatable }) => updatable)) { - const col = type === 'relation' ? `${name}Id` : name; + for (const { name, kind } of model.fields.filter(({ updatable }) => updatable)) { + const col = kind === 'relation' ? `${name}Id` : name; writer.writeLine(`${col}: row.${col},`); } @@ -198,10 +198,10 @@ export class MigrationGenerator { const revisionTable = `${model.name}Revision`; const missingRevisionFields = model.fields.filter( ({ name, updatable, ...field }) => - field.type !== 'raw' && + field.kind !== 'raw' && updatable && !this.columns[revisionTable].some( - (col) => col.name === (field.type === 'relation' ? field.foreignKey || `${name}Id` : name) + (col) => col.name === (field.kind === 'relation' ? field.foreignKey || `${name}Id` : name) ) ); @@ -210,11 +210,11 @@ export class MigrationGenerator { const revisionFieldsToRemove = model.fields.filter( ({ name, updatable, generated, ...field }) => !generated && - field.type !== 'raw' && + field.kind !== 'raw' && !updatable && - !(field.type === 'relation' && field.foreignKey === 'id') && + !(field.kind === 'relation' && field.foreignKey === 'id') && this.columns[revisionTable].some( - (col) => col.name === (field.type === 'relation' ? field.foreignKey || `${name}Id` : name) + (col) => col.name === (field.kind === 'relation' ? field.foreignKey || `${name}Id` : name) ) ); this.createRevisionFields(model, revisionFieldsToRemove, down, up); @@ -276,8 +276,8 @@ export class MigrationGenerator { for (const field of fields) { this.alterTable(model.name, () => { this.renameColumn( - field.type === 'relation' ? `${field.oldName}Id` : get(field, 'oldName'), - field.type === 'relation' ? `${field.name}Id` : field.name + field.kind === 'relation' ? `${field.oldName}Id` : get(field, 'oldName'), + field.kind === 'relation' ? `${field.name}Id` : field.name ); }); } @@ -287,8 +287,8 @@ export class MigrationGenerator { for (const field of fields) { this.alterTable(model.name, () => { this.renameColumn( - field.type === 'relation' ? `${field.name}Id` : field.name, - field.type === 'relation' ? `${field.oldName}Id` : get(field, 'oldName') + field.kind === 'relation' ? `${field.name}Id` : field.name, + field.kind === 'relation' ? `${field.oldName}Id` : get(field, 'oldName') ); }); } @@ -296,8 +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.type === 'relation' ? `${field.oldName!}Id` : field.oldName!).name = - field.type === 'relation' ? `${field.name}Id` : field.name; + summonByName(this.columns[model.name]!, field.kind === 'relation' ? `${field.oldName!}Id` : field.oldName!).name = + field.kind === 'relation' ? `${field.name}Id` : field.name; } } @@ -311,10 +311,10 @@ export class MigrationGenerator { const updates: Callbacks = []; const postAlter: Callbacks = []; for (const field of fields) { - alter.push(() => this.column(field, { setNonNull: field.default !== undefined })); + alter.push(() => this.column(field, { setNonNull: field.defaultValue !== undefined })); // If the field is not nullable but has no default, write placeholder code - if (field.nonNull && field.default === undefined) { + if (field.nonNull && field.defaultValue === undefined) { updates.push(() => this.writer.write(`${field.name}: 'TODO',`).newLine()); postAlter.push(() => this.column(field, { alter: true, foreign: false })); } @@ -343,8 +343,8 @@ export class MigrationGenerator { down.push(() => { this.alterTable(model.name, () => { - for (const { type, name } of fields) { - this.dropColumn(type === 'relation' ? `${name}Id` : name); + for (const { kind, name } of fields) { + this.dropColumn(kind === 'relation' ? `${name}Id` : name); } }); }); @@ -369,7 +369,7 @@ export class MigrationGenerator { this.column( field, { alter: true }, - summonByName(this.columns[model.name], field.type === 'relation' ? `${field.name}Id` : field.name) + summonByName(this.columns[model.name], field.kind === 'relation' ? `${field.name}Id` : field.name) ); } }); @@ -395,7 +395,7 @@ export class MigrationGenerator { this.column( field, { alter: true }, - summonByName(this.columns[model.name], field.type === 'relation' ? `${field.name}Id` : field.name) + summonByName(this.columns[model.name], field.kind === 'relation' ? `${field.name}Id` : field.name) ); } }); @@ -439,7 +439,7 @@ export class MigrationGenerator { this.writer .write(`await knex('${model.name}Revision').update(`) .inlineBlock(() => { - for (const { name, type } of missingRevisionFields) { + for (const { name, kind: type } of missingRevisionFields) { const col = type === 'relation' ? `${name}Id` : name; this.writer .write( @@ -467,7 +467,7 @@ export class MigrationGenerator { down.push(() => { this.alterTable(revisionTable, () => { for (const field of missingRevisionFields) { - this.dropColumn(field.type === 'relation' ? `${field.name}Id` : field.name); + this.dropColumn(field.kind === 'relation' ? `${field.name}Id` : field.name); } }); }); @@ -565,8 +565,8 @@ export class MigrationGenerator { } } } - if (setDefault && field.default !== undefined) { - this.writer.write(`.defaultTo(${this.value(field.default)})`); + if (setDefault && field.defaultValue !== undefined) { + this.writer.write(`.defaultTo(${this.value(field.defaultValue)})`); } if (primary) { this.writer.write('.primary()'); @@ -578,39 +578,44 @@ export class MigrationGenerator { } this.writer.write(';').newLine(); }; - 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})`); + const kind = field.kind; + switch (kind) { + case 'primitive': + 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; } 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}');`); + this.writer.writeLine(`table.foreign('${name}Id').references('id').inTable('${field.type}');`); } break; case 'enum': @@ -628,8 +633,15 @@ export class MigrationGenerator { } col(); break; - default: - throw new Error(`Unknown field type ${field.type}`); + case 'json': + this.writer.write(`table.json('${typeToField(field.type)}')`); + break; + case 'raw': + throw new Error("Raw fields aren't stored in the database"); + default: { + const exhaustiveCheck: never = kind; + throw new Error(exhaustiveCheck); + } } } } diff --git a/src/models/index.ts b/src/models/index.ts index 5f589da..d74b141 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,4 +1,5 @@ // created from 'create-ts-index' export * from './models'; +export * from './mutation-hook'; export * from './utils'; diff --git a/src/models/models.ts b/src/models/models.ts index 7af7991..d5a6bf6 100644 --- a/src/models/models.ts +++ b/src/models/models.ts @@ -1,62 +1,48 @@ -import { DateTime } from 'luxon'; import { Field } from '..'; -import type { Context } from '../context'; import type { OrderBy } from '../resolvers/arguments'; import type { Value } from '../values'; export type RawModels = RawModel[]; -export type RawModel = ScalarModel | EnumModel | RawEnumModel | InterfaceModel | ObjectModel | RawObjectModel; - -type BaseModel = { +export type RawModel = { name: string; plural?: string; description?: string; -}; - -export type ScalarModel = BaseModel & { type: 'scalar' }; - -export type EnumModel = BaseModel & { type: 'enum'; values: string[]; deleted?: true }; - -export type RawEnumModel = BaseModel & { type: 'raw-enum'; values: string[] }; - -export type InterfaceModel = BaseModel & { type: 'interface'; fields: ModelField[] }; - -export type RawObjectModel = BaseModel & { - type: 'raw'; - fields: RawObjectField[]; -}; +} & ( + | { kind: 'scalar' } + | { kind: 'enum'; values: string[]; deleted?: true } + | { kind: 'raw-enum'; values: string[] } + | { kind: 'interface'; fields: ModelField[] } + | { + kind: 'raw'; + fields: RawObjectField[]; + } + | { + kind: 'object'; + interfaces?: string[]; + queriable?: boolean; + listQueriable?: 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[]; + + // temporary fields for the generation of migrations + deleted?: true; + oldName?: string; + } +); -export type Entity = Record & { createdAt?: DateTime; deletedAt?: DateTime }; - -export type Action = 'create' | 'update' | 'delete' | 'restore'; - -export type MutationHook = ( - model: Model, - action: Action, - when: 'before' | 'after', - data: { prev: Entity; input: Entity; normalizedInput: Entity; next: Entity }, - ctx: Context -) => Promise; - -export type ObjectModel = BaseModel & { - type: 'object'; - interfaces?: string[]; - queriable?: boolean; - listQueriable?: 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[]; - - // temporary fields for the generation of migrations - deleted?: true; - oldName?: string; -}; +export type ScalarModel = Extract; +export type EnumModel = Extract; +export type RawEnumModel = Extract; +export type InterfaceModel = Extract; +export type RawObjectModel = Extract; +export type ObjectModel = Extract; type BaseNumberType = { unit?: 'million'; @@ -64,46 +50,48 @@ type BaseNumberType = { max?: number; }; -type BaseField = Omit; +type FieldBase = 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 & +type FieldBase2 = + | ({ kind?: 'primitive' | undefined } & ( + | { 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' } + )) + | { kind: 'enum'; type: string; possibleValues?: Value[] } + | { kind: 'raw'; type: string }; + +export type RawObjectField = FieldBase & FieldBase2; + +export type ModelField = FieldBase & ( - | PrimitiveField - | { type: 'json'; typeName: string } - | { type: 'enum'; typeName: string; possibleValues?: Value[] } - | { type: 'raw'; typeName: string } + | FieldBase2 + | { kind: 'json'; type: string } | { - type: 'relation'; - typeName: string; + kind: 'relation'; + type: string; toOne?: boolean; reverse?: string; foreignKey?: string; @@ -138,7 +126,6 @@ export type ModelField = BaseField & 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; @@ -148,18 +135,22 @@ export type ModelField = BaseField & // temporary fields for the generation of migrations deleted?: true; oldName?: string; + + meta?: Record; }; -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 PrimitiveField = Extract; +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 UploadField = Extract; +export type JsonField = Extract; +export type EnumField = Extract; +export type RawField = Extract; +export type RelationField = Extract; export type Models = Model[]; diff --git a/src/models/mutation-hook.ts b/src/models/mutation-hook.ts new file mode 100644 index 0000000..ce6e00c --- /dev/null +++ b/src/models/mutation-hook.ts @@ -0,0 +1,17 @@ +import { DateTime } from 'luxon'; +import { Model } from '.'; +import { Context } from '..'; + +export type Entity = Record & { createdAt?: DateTime; deletedAt?: DateTime }; + +export type FullEntity = Entity & { id: string }; + +export type Action = 'create' | 'update' | 'delete' | 'restore'; + +export type MutationHook = ( + model: Model, + action: Action, + when: 'before' | 'after', + data: { prev: Entity; input: Entity; normalizedInput: Entity; next: FullEntity }, + ctx: Context +) => Promise; diff --git a/src/models/utils.ts b/src/models/utils.ts index d0d1eeb..fead5ff 100644 --- a/src/models/utils.ts +++ b/src/models/utils.ts @@ -7,11 +7,13 @@ import startCase from 'lodash/startCase'; import { BooleanField, DateTimeField, + EnumField, EnumModel, Model, ModelField, Models, ObjectModel, + PrimitiveField, RawEnumModel, RawField, RawModel, @@ -43,18 +45,18 @@ 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 isObjectModel = (model: RawModel): model is ObjectModel => model.kind === 'object'; -export const isEnumModel = (model: RawModel): model is EnumModel => model.type === 'enum'; +export const isEnumModel = (model: RawModel): model is EnumModel => model.kind === 'enum'; -export const isRawEnumModel = (model: RawModel): model is RawEnumModel => model.type === 'raw-enum'; +export const isRawEnumModel = (model: RawModel): model is RawEnumModel => model.kind === 'raw-enum'; -export const isScalarModel = (model: RawModel): model is ScalarModel => model.type === 'scalar'; +export const isScalarModel = (model: RawModel): model is ScalarModel => model.kind === 'scalar'; -export const isRawObjectModel = (model: RawModel): model is RawObjectModel => model.type === 'raw'; +export const isRawObjectModel = (model: RawModel): model is RawObjectModel => model.kind === 'raw'; export const isEnumList = (models: RawModels, field: ModelField) => - field?.list === true && models.find(({ name }) => name === field.type)?.type === 'enum'; + field?.list === true && models.find(({ name }) => name === field.kind)?.kind === 'enum'; export const and = (...predicates: ((field: ModelField) => boolean)[]) => @@ -63,13 +65,18 @@ export const and = export const not = (predicate: (field: ModelField) => boolean) => (field: ModelField) => !predicate(field); -export const isRelation = (field: ModelField): field is RelationField => field.type === 'relation'; +export const isPrimitive = (field: ModelField): field is PrimitiveField => + field.kind === undefined || field.kind === 'primitive'; + +export const isEnum = (field: ModelField): field is EnumField => field.kind === 'enum'; + +export const isRelation = (field: ModelField): field is RelationField => field.kind === '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 isRaw = (field: ModelField): field is RawField => field.kind === 'raw'; export const isVisible = ({ hidden }: ModelField) => hidden !== true; @@ -114,7 +121,6 @@ export const getModels = (rawModels: RawModels): Models => { { name: 'createdAt', type: 'DateTime', - nonNull: true, orderable: true, generated: true, @@ -122,8 +128,8 @@ export const getModels = (rawModels: RawModels): Models => { } satisfies DateTimeField, { name: 'createdBy', - type: 'relation', - typeName: 'User', + kind: 'relation', + type: 'User', nonNull: true, reverse: `created${getModelPlural(model)}`, generated: true, @@ -143,8 +149,8 @@ export const getModels = (rawModels: RawModels): Models => { } satisfies DateTimeField, { name: 'updatedBy', - type: 'relation', - typeName: 'User', + kind: 'relation', + type: 'User', nonNull: true, reverse: `updated${getModelPlural(model)}`, generated: true, @@ -158,7 +164,7 @@ export const getModels = (rawModels: RawModels): Models => { name: 'deleted', type: 'Boolean', nonNull: true, - default: false, + defaultValue: false, filterable: { default: false }, generated: true, ...(typeof model.deletable === 'object' && model.deletable.deleted), @@ -172,8 +178,8 @@ export const getModels = (rawModels: RawModels): Models => { } satisfies DateTimeField, { name: 'deletedBy', - type: 'relation', - typeName: 'User', + kind: 'relation', + type: 'User', reverse: `deleted${getModelPlural(model)}`, generated: true, ...(typeof model.deletable === 'object' && model.deletable.deletedBy), @@ -183,7 +189,7 @@ export const getModels = (rawModels: RawModels): Models => { ] satisfies ModelField[] ).map((field: ModelField) => ({ ...field, - ...(field.type === 'relation' && { + ...(field.kind === 'relation' && { foreignKey: field.foreignKey || `${field.name}Id`, }), })), @@ -198,17 +204,17 @@ export const getModels = (rawModels: RawModels): Models => { for (const model of models) { for (const field of model.fields) { - if (field.type !== 'relation') { + if (field.kind !== 'relation') { continue; } - const fieldModel = summonByName(models, field.typeName); + const fieldModel = summonByName(models, field.type); const reverseRelation: ReverseRelation = { - type: 'relation', + kind: 'relation', name: field.reverse || (field.toOne ? typeToField(model.name) : getModelPluralField(model)), foreignKey: get(field, 'foreignKey'), - typeName: model.name, + type: model.name, toOne: !!field.toOne, fieldModel, field, diff --git a/src/permissions/check.ts b/src/permissions/check.ts index 96b921b..84c5464 100644 --- a/src/permissions/check.ts +++ b/src/permissions/check.ts @@ -147,8 +147,8 @@ export const checkCanWrite = async ( continue; } - const fieldPermissions = field[action === 'CREATE' ? 'creatableBy' : 'updatableBy']; - if (fieldPermissions && !fieldPermissions.includes(ctx.user.role)) { + const fieldPermissions = field[action === 'CREATE' ? 'creatable' : 'updatable']; + if (fieldPermissions && typeof fieldPermissions === 'object' && !fieldPermissions.roles?.includes(ctx.user.role)) { throw new PermissionError(action, `this ${model.name}'s ${field.name}`, 'field permission not available'); } diff --git a/src/resolvers/filters.ts b/src/resolvers/filters.ts index d4625de..6d6e6bd 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.type === 'relation') { + if (field.kind === '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 195ff7d..d5e803a 100644 --- a/src/resolvers/mutations.ts +++ b/src/resolvers/mutations.ts @@ -3,8 +3,9 @@ import { DateTime } from 'luxon'; import { v4 as uuid } from 'uuid'; import { Context, FullContext } from '../context'; import { ForbiddenError, GraphQLError } from '../errors'; -import { Entity, Model, ModelField } from '../models/models'; -import { get, isEnumList, it, summonByName, typeToField } from '../models/utils'; +import { Model, ModelField } from '../models/models'; +import { Entity, FullEntity } from '../models/mutation-hook'; +import { get, isEnumList, isPrimitive, it, summonByName, typeToField } from '../models/utils'; import { applyPermissions, checkCanWrite, getEntityToMutate } from '../permissions/check'; import { resolve } from './resolver'; import { AliasGenerator } from './utils'; @@ -106,7 +107,7 @@ const del = async (model: Model, { where, dryRun }: { where: any; dryRun: boolea const mutations: Callbacks = []; const afterHooks: Callbacks = []; - const deleteCascade = async (currentModel: Model, entity: Entity) => { + const deleteCascade = async (currentModel: Model, entity: FullEntity) => { if (entity.deleted) { return; } @@ -220,7 +221,7 @@ const restore = async (model: Model, { where }: { where: any }, ctx: FullContext const mutations: Callbacks = []; const afterHooks: Callbacks = []; - const restoreCascade = async (currentModel: Model, relatedEntity: Entity) => { + const restoreCascade = async (currentModel: Model, relatedEntity: FullEntity) => { if (!relatedEntity.deleted || !relatedEntity.deletedAt || !relatedEntity.deletedAt.equals(entity.deletedAt)) { return; } @@ -265,7 +266,7 @@ const restore = async (model: Model, { where }: { where: any }, ctx: FullContext const createRevision = async (model: Model, data: Entity, ctx: Context) => { if (model.updatable) { - const revisionData = { + const revisionData: Entity = { id: uuid(), [`${typeToField(model.name)}Id`]: data.id, createdAt: ctx.now, @@ -276,10 +277,10 @@ const createRevision = async (model: Model, data: Entity, ctx: Context) => { revisionData.deleted = data.deleted || false; } - for (const { type, name, nonNull, ...field } of model.fields.filter(({ updatable }) => updatable)) { + for (const { kind: 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'); + revisionData[col] = get(field, 'defaultValue'); } else { revisionData[col] = data[col]; } @@ -314,4 +315,4 @@ const sanitize = (ctx: FullContext, model: Model, data: Entity) => { }; const isEndOfDay = (field?: ModelField) => - field.type === 'DateTime' && field?.endOfDay === true && field?.dateTimeType === 'date' && field?.type === 'DateTime'; + isPrimitive(field) && field.type === 'DateTime' && field?.endOfDay === true && field?.dateTimeType === 'date'; diff --git a/src/resolvers/node.ts b/src/resolvers/node.ts index 0e042cd..e626a7d 100644 --- a/src/resolvers/node.ts +++ b/src/resolvers/node.ts @@ -113,7 +113,7 @@ export const getSimpleFields = (node: ResolverNode) => { return true; } - return node.model.fields.some(({ type, name }) => type === 'json' && name === selection.name.value); + return node.model.fields.some(({ kind: type, name }) => type === 'json' && name === selection.name.value); }); }; @@ -165,7 +165,7 @@ export const getJoins = (node: ResolverNode, toMany: boolean) => { foreignKey = reverseRelation.foreignKey; } else { const modelField = baseModel.fieldsByName[fieldName]; - if (modelField?.type !== 'relation') { + if (modelField?.kind !== 'relation') { continue; } foreignKey = modelField.foreignKey; diff --git a/src/resolvers/resolver.ts b/src/resolvers/resolver.ts index 63fa8fb..525c4e6 100644 --- a/src/resolvers/resolver.ts +++ b/src/resolvers/resolver.ts @@ -115,7 +115,7 @@ 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.type === 'relation' || field.type === 'raw') { + if (!field || field.kind === 'relation' || field.kind === 'raw') { return false; } diff --git a/src/schema/generate.ts b/src/schema/generate.ts index 0b23c28..d6adcd8 100644 --- a/src/schema/generate.ts +++ b/src/schema/generate.ts @@ -1,4 +1,4 @@ -import { buildASTSchema, DefinitionNode, DocumentNode, GraphQLSchema, print } from 'graphql'; +import { DefinitionNode, DocumentNode, GraphQLSchema, buildASTSchema, print } from 'graphql'; import flatMap from 'lodash/flatMap'; import { RawModels } from '../models/models'; import { @@ -12,7 +12,7 @@ import { isScalarModel, typeToField, } from '../models/utils'; -import { document, enm, Field, input, object, scalar } from './utils'; +import { Field, document, enm, input, object, scalar } from './utils'; export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { const models = getModels(rawModels); @@ -30,17 +30,13 @@ export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { ...rawModels .filter(isRawObjectModel) .filter((model) => - models.some( - (m) => m.creatable && m.fields.some((f) => f.creatable && f.type === 'json' && f.typeName === model.name) - ) + models.some((m) => m.creatable && m.fields.some((f) => f.creatable && f.kind === 'json' && f.type === model.name)) ) .map((model) => input(`Create${model.name}`, model.fields)), ...rawModels .filter(isRawObjectModel) .filter((model) => - models.some( - (m) => m.creatable && m.fields.some((f) => f.creatable && f.type === 'json' && f.typeName === model.name) - ) + models.some((m) => m.creatable && m.fields.some((f) => f.creatable && f.kind === 'json' && f.type === model.name)) ) .map((model) => input(`Update${model.name}`, model.fields)), @@ -52,10 +48,7 @@ export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { [ ...model.fields.filter(isQueriableField).map((field) => ({ ...field, - type: - field.type === 'relation' || field.type === 'enum' || field.type === 'raw' || field.type === 'json' - ? field.typeName - : field.type, + type: field.type, args: [...(field.args || [])], directives: field.directives, })), @@ -79,33 +72,33 @@ export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { ), input(`${model.name}Where`, [ ...model.fields - .filter(({ type, unique, filterable }) => (unique || filterable) && type !== 'relation') - .map(({ type, name, filterable }) => ({ - name, - type, + .filter(({ kind, unique, filterable }) => (unique || filterable) && kind !== 'relation') + .map((field) => ({ + name: field.name, + type: field.type, list: true, - default: typeof filterable === 'object' ? filterable.default : undefined, + default: typeof field.filterable === 'object' ? field.filterable.default : undefined, })), ...flatMap( model.fields.filter(({ comparable }) => comparable), - ({ name, type }) => [ - { name: `${name}_GT`, type }, - { name: `${name}_GTE`, type }, - { name: `${name}_LT`, type }, - { name: `${name}_LTE`, type }, + (field) => [ + { name: `${field.name}_GT`, type: field.type }, + { name: `${field.name}_GTE`, type: field.type }, + { name: `${field.name}_LT`, type: field.type }, + { name: `${field.name}_LTE`, type: field.type }, ] ), ...model.fields .filter(isRelation) .filter(({ filterable }) => filterable) - .map(({ name, typeName }) => ({ + .map(({ name, type }) => ({ name, - type: `${typeName}Where`, + type: `${type}Where`, })), ]), input( `${model.name}WhereUnique`, - model.fields.filter(({ unique }) => unique).map(({ name, type }) => ({ name, type })) + model.fields.filter(({ unique }) => unique).map((field) => ({ name: field.name, type: field.type })) ), ...(model.fields.some(({ orderable }) => orderable) ? [ @@ -123,19 +116,14 @@ export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { `Create${model.name}`, model.fields .filter(({ creatable }) => creatable) - .map(({ name, nonNull, list, default: defaultValue, ...field }) => - field.type === 'relation' - ? { name: `${name}Id`, type: 'ID', nonNull } + .map((field) => + field.kind === 'relation' + ? { name: `${field.name}Id`, type: 'ID', nonNull: field.nonNull } : { - name, - type: - field.type === 'enum' - ? field.typeName - : field.type === 'json' - ? `Create${field.typeName}` - : field.type, - list, - nonNull: nonNull && defaultValue === undefined, + name: field.name, + type: field.kind === 'json' ? `Create${field.type}` : field.type, + list: field.list, + nonNull: field.nonNull && field.defaultValue === undefined, } ) ) @@ -148,18 +136,13 @@ export const generateDefinitions = (rawModels: RawModels): DefinitionNode[] => { `Update${model.name}`, model.fields .filter(({ updatable }) => updatable) - .map(({ name, list, ...field }) => - field.type === 'relation' - ? { name: `${name}Id`, type: 'ID' } + .map((field) => + field.kind === 'relation' + ? { name: `${field.name}Id`, type: 'ID' } : { - name, - type: - field.type === 'enum' - ? field.typeName - : field.type === 'json' - ? `Update${field.typeName}` - : field.type, - list, + name: field.name, + type: field.kind === 'json' ? `Update${field.type}` : field.type, + list: field.list, } ) ) diff --git a/src/schema/utils.ts b/src/schema/utils.ts index 0927e59..d8e2cfc 100644 --- a/src/schema/utils.ts +++ b/src/schema/utils.ts @@ -30,7 +30,7 @@ export type Field = { description?: string; list?: boolean; nonNull?: boolean; - default?: Value; + defaultValue?: Value; args?: Field[]; directives?: Directive[]; }; @@ -90,7 +90,7 @@ export const inputValues = (fields: Field[]): InputValueDefinitionNode[] => kind: 'InputValueDefinition', name: name(field.name), type: fieldType(field), - defaultValue: field.default === undefined ? undefined : value(field.default), + defaultValue: field.defaultValue === undefined ? undefined : value(field.defaultValue), directives: directives(field.directives), }) ); @@ -105,7 +105,7 @@ export const fields = (fields: Field[]): FieldDefinitionNode[] => kind: 'InputValueDefinition', name: name(arg.name), type: fieldType(arg), - defaultValue: arg.default === undefined ? undefined : value(arg.default), + defaultValue: arg.defaultValue === undefined ? undefined : value(arg.defaultValue), })), directives: directives(field.directives), }) diff --git a/tests/unit/__snapshots__/generate.spec.ts.snap b/tests/unit/__snapshots__/generate.spec.ts.snap index 3f56b4d..dd5e020 100644 --- a/tests/unit/__snapshots__/generate.spec.ts.snap +++ b/tests/unit/__snapshots__/generate.spec.ts.snap @@ -19,7 +19,7 @@ input AnotherObjectOrderBy { input AnotherObjectWhere { id: [ID!] - deleted: [Boolean!] = false + deleted: [Boolean!] } input AnotherObjectWhereUnique { @@ -88,7 +88,7 @@ input SomeObjectOrderBy { input SomeObjectWhere { id: [ID!] - deleted: [Boolean!] = false + deleted: [Boolean!] another: AnotherObjectWhere } diff --git a/tests/utils/models.ts b/tests/utils/models.ts index 42a4dd3..592a6b4 100644 --- a/tests/utils/models.ts +++ b/tests/utils/models.ts @@ -5,23 +5,23 @@ import { generatePermissions, PermissionsConfig } from '../../src/permissions/ge export const rawModels: RawModels = [ { name: 'SomeEnum', - type: 'enum', + kind: 'enum', values: ['A', 'B', 'C'], }, { name: 'Role', - type: 'enum', + kind: 'enum', values: ['ADMIN', 'USER'], }, { name: 'SomeRawObject', - type: 'raw', + kind: 'raw', fields: [{ name: 'field', type: 'String' }], }, { - type: 'object', + kind: 'object', name: 'User', fields: [ { @@ -30,13 +30,13 @@ export const rawModels: RawModels = [ }, { name: 'role', - type: 'enum', - typeName: 'Role', + kind: 'enum', + type: 'Role', }, ], }, { - type: 'object', + kind: 'object', name: 'AnotherObject', listQueriable: true, deletable: true, @@ -48,8 +48,8 @@ export const rawModels: RawModels = [ orderable: true, }, { - type: 'relation', - typeName: 'AnotherObject', + kind: 'relation', + type: 'AnotherObject', name: 'myself', toOne: true, reverse: 'self', @@ -57,7 +57,7 @@ export const rawModels: RawModels = [ ], }, { - type: 'object', + kind: 'object', name: 'SomeObject', plural: 'ManyObjects', description: 'An object', @@ -74,8 +74,8 @@ export const rawModels: RawModels = [ }, { name: 'another', - type: 'relation', - typeName: 'AnotherObject', + kind: 'relation', + type: 'AnotherObject', filterable: true, updatable: true, nonNull: true,