diff --git a/src/helpers/sql.ts b/src/helpers/sql.ts index b64e7d4..b1bf70c 100644 --- a/src/helpers/sql.ts +++ b/src/helpers/sql.ts @@ -12,4 +12,18 @@ export function sql(strings: TemplateStringsArray, ...keys: any[]) { if (i < (strings.length - 1)) sqlOutput += '$' + (i + 1); } return { sql: sqlOutput, params: keys }; +} + +/** + * Merges multiple SqlStatement objects into a single one + */ +export function mergeSql(...sqlStatements: SqlStatement[]): SqlStatement { + let sqlOutput = ''; + const params = []; + for (const sqls of sqlStatements) { + sqlOutput += sqls.sql + ' '; + params.push(...sqls.params); + } + sqlOutput = sqlOutput.split(/\$\d+/).map((part, i) => i > 0 ? ('$' + i + part) : part).join(''); + return { sql: sqlOutput.trim(), params }; } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 4a72858..c491b1e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,114 +1,28 @@ -import { ConnectionDetails as PgConnectionDetails, PostgresqlDriver } from './drivers/postgresql.js'; -import type { ConnectionDetails as PgLiteConnectionDetails } from './drivers/pglite.js'; -import { ConnectionDetails as DummyConnectionDetails, DummyDriver } from './drivers/dummy.js'; -import { BaseEntity, defineCustomField, defineEntity, defineField, defineIdField } from './entities/BaseEntity.js'; -import { camelCaseToSnakeCase, CamelToSnakeCase, IdSuffixed, snakeCaseToCamelCase, suffixId } from './util/strings.js'; -import { sql, SqlStatement } from './helpers/sql.js'; import format from 'pg-format'; +import { ChangeTracker } from './transaction/ChangeTracker.js'; import { Driver } from './drivers/Driver.js'; -import { ChangeTracker } from './ChangeTracker.js'; -import { OrmError } from './errors/OrmError.js'; +import { ConnectionDetails as DummyConnectionDetails, DummyDriver } from './drivers/dummy.js'; +import type { ConnectionDetails as PgLiteConnectionDetails } from './drivers/pglite.js'; +import { ConnectionDetails as PgConnectionDetails, PostgresqlDriver } from './drivers/postgresql.js'; import { checkEntityDefinitions } from './entities/entityDefinitionsChecks.js'; -import { validateSchema as validateSchemaActual } from './validateSchema.js'; +import { EntityCache } from './transaction/EntityCache.js'; +import { OrmError } from './errors/OrmError.js'; import { SchemaValidationError } from './errors/SchemaValidationError.js'; -import { EntityCache } from './EntityCache.js'; import EventEmitter from './helpers/MyEventEmitter.js'; -import { RelationCache } from './RelationCache.js'; - -// Magic getter constant -// This is used on custom property getters to mark them as ORM relation getters -const ormRelationGetter = Symbol('ormRelationGetter'); - -export function _isOrmRelationGetter(object: any, property: string) { - const getter = Object.getOwnPropertyDescriptor(object, property)?.get; - if (getter == null) return false; - return ormRelationGetter in getter && getter[ormRelationGetter] != null && getter[ormRelationGetter] == true; -} +import { mergeSql, sql, SqlStatement } from './helpers/sql.js'; +import { RelationCache } from './transaction/RelationCache.js'; +import { BaseEntityDefinitions } from './types/BaseEntityDefinitions.js'; +import { EntityByName, EntityDefinition, EntityName } from './types/EntityTypes.js'; +import { OutputType } from './types/OutputType.js'; +import { camelCaseToSnakeCase, snakeCaseToCamelCase, suffixId } from './util/strings.js'; +import { validateSchema as validateSchemaActual } from './tools/validateSchema.js'; +import { InputType } from './types/InputType.js'; +import { RawSqlType } from './types/RawSqlType.js'; +import { isOrmRelationGetter, ormRelationGetter } from './relations/ormRelationGetter.js'; export type QueryStatsQuery = { query: string, params: any[], durationInMs: number }; export type QueryStats = { queries: QueryStatsQuery[] }; -type IsNullable = T extends true ? null : never; - -type BaseEntityDefinitions = Record; - -// Entity helper types -type EntityName = keyof ED; -type EntityDefinition = ED[EntityName]; -type EntityByName> = ED[K]; - -// Helper types to get field properties for a specific entity -type Fields> = E['fields']; -type FieldNames> = keyof Fields; -type Field, N extends FieldNames> = Fields[N]; -type FieldType, N extends FieldNames> = ReturnType['toType']>; -type FieldNullNever, N extends FieldNames> = IsNullable['nullableOnOutput']>; - -// // Helper types for relations for a specific entity -type RelationNames> = keyof E['oneToOneOwned'] | keyof E['oneToOneInverse'] | keyof E['manyToOne'] | keyof E['oneToMany']; - -// Prep the output type for a specific entity -// Defines a type for every field on a given entity, including correctly nullability setting -// Defines the types for all relations, which will something like Promise or Promise, depending on the relation type, with correct nullability -export type OutputType> = EvalGeneric<{ - -readonly [N in keyof E['fields']]: FieldType | FieldNullNever -} & { - -readonly [N in keyof E['oneToOneOwned']]: Promise>> | IsNullable -} & { - -readonly [N in keyof E['oneToOneInverse']]: Promise> | IsNullable> -} & { - -readonly [N in keyof E['manyToOne']]: Promise>> | IsNullable -} & { - [N in keyof E['oneToMany']]: Promise>[]> -}>; - -// This type is an identity type: the output is identical to the input, except it forces the TypeScript compiler to evaluate the type -// Collapsing the type is useful both for performance reasons (it makes TypeScript horribly slow otherwise in some scenarios) -// and it has the added benefit of making the types much more readable in error messages and IDE inlay hints -// type EvalGeneric = X extends infer C ? { [K in keyof C]: C[K] } : never; -type EvalGeneric = unknown extends X - ? X - : (X extends infer C ? { [K in keyof C]: C[K] } : never); - -type OutputTypeRef> = Promise> | OutputType; - -// Define input type for save functions -type InputTypeFields> = - { -readonly [N in keyof E['fields'] as E['fields'][N]['nullableOnInput'] extends true ? never : N]: FieldType } // mandatory properties - & { -readonly [N in keyof E['fields'] as E['fields'][N]['nullableOnInput'] extends true ? N : never]?: FieldType | null | undefined }; // optionals - -type InputTypeOneToOneOwned> = - { -readonly [N in keyof E['oneToOneOwned'] as E['oneToOneOwned'][N]['nullable'] extends true ? never : N]: OutputTypeRef> } // mandatory properties - & { -readonly [N in keyof E['oneToOneOwned'] as E['oneToOneOwned'][N]['nullable'] extends true ? N : never]?: OutputTypeRef> | null | undefined }; // optionals - -type InputTypeManyToOne> = - { -readonly [N in keyof E['manyToOne'] as E['manyToOne'][N]['nullable'] extends true ? never : N]: OutputTypeRef> } // mandatory properties - & { -readonly [N in keyof E['manyToOne'] as E['manyToOne'][N]['nullable'] extends true ? N : never]?: OutputTypeRef> | null | undefined }; // optionals - -type InputTypeWithId> = - InputTypeFields & InputTypeOneToOneOwned & InputTypeManyToOne & { - -readonly [N in keyof E['oneToOneInverse']]?: any /* Allow feeding something, but we don't care what exactly since we won't save it. We could narrow this to identical to OutputType<> */ - } & { - -readonly [N in keyof E['oneToMany']]?: any /* Allow feeding something, but we don't care what exactly since we won't save it. We could narrow this to identical to OutputType<> */ - }; - -type WithOptionalId = Omit & { id?: string | null }; - -export type InputType> = EvalGeneric>>; - -// Construct the RawSqlType, which is a type definition that encompasses what is returned from a SQL-query -// Currently any types for OneToMany relations that aren't defined as a ManyToOne on the other side are missing -// (e.g if B defines a OneToMany to A, then A will not have a field for this relation, even though in reality there will be a b_id column in the table for A) -// The clunky N extends string stuff is to ensure TypeScript does not get confused. Even though the keys of FieldNames<> and such are always strings, -// for some reason TS tries to use the default key type (symbol | number | string) instead of the more narrow string we know it to be. -export type RawSqlType> = { - -readonly [N in keyof E['fields'] as CamelToSnakeCase>]: ReturnType | IsNullable -} & { - -readonly [N in keyof E['oneToOneOwned'] as IdSuffixed>>]: number | IsNullable -} & { - -readonly [N in keyof E['manyToOne'] as IdSuffixed>>]: number | IsNullable -}; - // Represents different parts of findMany queries // We might add specifics here later for specific entities, hence the type param type WhereClause> = SqlStatement; @@ -128,27 +42,12 @@ function orderByToSql i > 0 ? ('$' + i + part) : part).join(''); - return { sql: sqlOutput.trim(), params }; -} - type TransactionControls = { query: (query: string, params: any[]) => Promise<{ rows: any[] }>, commit: () => Promise, rollback: () => Promise, }; - type DbFunctions = { findOne: >(entityName: N, id: string) => Promise>>, findOneOrNull: >(entityName: N, id: string) => Promise> | null>, @@ -249,32 +148,32 @@ export class Farstorm extends EventEmitt * IMPORTANT: inserts/updates/deletions through native queries will not be tracked by the audit log system */ async enableAuditLogging() { - const tx = await this.driver.startTransaction(); - - try { - // Run schema validation with a fictive entity to validate the schema is actually what we expect - // const result = await validateSchemaActual({ - // AuditLog: defineEntity({ - // fields: { - // id: defineIdField(), - // timestamp: defineField('Date', false), - // transactionId: defineField('number', false), - // table: defineField('string', false), - // entityId: defineField('number', false), - // type: defineCustomField(false, x => x as ('INSERT' | 'UPDATE' | 'DELETE'), x => x), - // diff: defineCustomField(false, x => x, x => x), - // metadata: defineCustomField(false, x => x, x => x), - // }, - // }), - // }, statement => tx.query(statement.sql, statement.params).then(r => r.rows)); - // if (!result.valid) { - // this.auditLoggingEnabled = false; - // throw Error('Cannot enable audit logging due to schema mismatch'); - // } - this.auditLoggingEnabled = true; - } finally { - tx.commit(); - } + // const tx = await this.driver.startTransaction(); + + // try { + // // Run schema validation with a fictive entity to validate the schema is actually what we expect + // const result = await validateSchemaActual({ + // AuditLog: defineEntity({ + // fields: { + // id: defineIdField(), + // timestamp: defineField('Date', false), + // transactionId: defineField('number', false), + // table: defineField('string', false), + // entityId: defineField('number', false), + // type: defineCustomField(false, x => x as ('INSERT' | 'UPDATE' | 'DELETE'), x => x), + // diff: defineCustomField(false, x => x, x => x), + // metadata: defineCustomField(false, x => x, x => x), + // }, + // }), + // }, statement => tx.query(statement.sql, statement.params).then(r => r.rows)); + // if (!result.valid) { + // this.auditLoggingEnabled = false; + // throw Error('Cannot enable audit logging due to schema mismatch'); + // } + this.auditLoggingEnabled = true; + // } finally { + // tx.commit(); + // } } /** @@ -312,7 +211,7 @@ export class Farstorm extends EventEmitt * This takes the raw SQL result and converts it into a proper OutputType for the entity * This function is responsible for putting in the Promise getters which will actually fetch any relations */ - const prepEntity = >(entityName: N, result: RawSqlType>): OutputType> => { + const createOutputTypeFromRawSqlType = >(entityName: N, result: RawSqlType>): OutputType> => { const entityDefinition = this.entityDefinitions[entityName]; const output: Record = {}; // Realistically the type is way stricter, more like Record> | RelationNames>, any> @@ -347,7 +246,7 @@ export class Farstorm extends EventEmitt if (rawResult == null && !relation.nullable) { throw new OrmError('ORM-1121', { entity: entityName as string, relation: relationName, operation: 'resolve-one-to-one-owned' }, queryStatistics.queries); } - return rawResult == null ? null : prepEntity(relation.entity, rawResult); + return rawResult == null ? null : createOutputTypeFromRawSqlType(relation.entity, rawResult); }; getOneToOneRelation[ormRelationGetter] = true; Object.defineProperty(output, relationName, { enumerable: true, configurable: true, get: getOneToOneRelation, set: (value) => { Object.defineProperty(output, relationName, { value }); } }); @@ -377,7 +276,7 @@ export class Farstorm extends EventEmitt if (rawResult == null && !relation.nullable) { throw new OrmError('ORM-1121', { entity: entityName as string, relation: relationName, operation: 'resolve-many-to-one' }, queryStatistics.queries); } - return rawResult == null ? null : prepEntity(relation.entity, rawResult); + return rawResult == null ? null : createOutputTypeFromRawSqlType(relation.entity, rawResult); }; getManyToOneRelation[ormRelationGetter] = true; Object.defineProperty(output, relationName, { enumerable: true, configurable: true, get: getManyToOneRelation, set: (value) => { Object.defineProperty(output, relationName, { value }); } }); @@ -403,7 +302,7 @@ export class Farstorm extends EventEmitt throw new OrmError('ORM-1101', { entity: entityName as string, relation: relationName, operation: 'resolve-one-to-one-inverse' }, queryStatistics.queries); } else { const item = items[0] ?? null; - return item != null ? prepEntity(relation.entity, item) : null; + return item != null ? createOutputTypeFromRawSqlType(relation.entity, item) : null; } }; getOneToOneInverseRelation[ormRelationGetter] = true; @@ -426,7 +325,7 @@ export class Farstorm extends EventEmitt return (cached.inverseMap[result.id as string] ?? []) .map(rawEntity => localCache.get(relation.entity, rawEntity.id)) .filter(cachedEntity => cachedEntity != null) - .map(cachedEntity => prepEntity(relation.entity, cachedEntity!)); + .map(cachedEntity => createOutputTypeFromRawSqlType(relation.entity, cachedEntity!)); }; getOneToMany[ormRelationGetter] = true; Object.defineProperty(output, relationName, { enumerable: true, configurable: true, get: getOneToMany }); @@ -434,105 +333,7 @@ export class Farstorm extends EventEmitt return output as OutputType>; }; - - /** - * User facing function, fetches a single entity from the database - * This function will throw if the entity is not found - */ - async function findOne>(entityName: N, id: string): Promise>> { - if (transactionControls == null) throw new OrmError('ORM-1000', { entity: entityName as string, operation: 'findOne' }); - - const rows = await nativeQuery({ sql: `select * from "${camelCaseToSnakeCase(entityName as string)}" where "id" = $1`, params: [ id ] }); - if (rows == null || rows.length == 0) throw new OrmError('ORM-1200', { entity: entityName as string, operation: 'findOne' }); - if (rows.length > 1) throw new OrmError('ORM-1201', { entity: entityName as string, operation: 'findOne' }); - - // Update the loaded entities cache - updateCacheWithNewEntities(entityName, rows); - - return prepEntity(entityName, rows[0]) as any; - } - - /** - * Fetches a single entity from the database, but returns null if the entity is not found - */ - async function findOneOrNull>(entityName: N, id: string): Promise> | null> { - if (transactionControls == null) throw new OrmError('ORM-1000', { entity: entityName as string, operation: 'findOneOrNull' }); - - const rows = await nativeQuery({ sql: `select * from "${camelCaseToSnakeCase(entityName as string)}" where "id" = $1`, params: [ id ] }); - if (rows == null || rows.length == 0) return null; - if (rows.length > 1) throw new OrmError('ORM-1201', { entity: entityName as string, operation: 'findOneOrNull' }); - - // Update the loaded entities cache - updateCacheWithNewEntities(entityName, rows); - - return prepEntity(entityName, rows[0]) as OutputType>; - } - - /** - * Fetches a list of entities from the database by their IDs - */ - async function findByIds>(entityName: N, ids: string[]) { - if (transactionControls == null) throw new OrmError('ORM-1000', { entity: entityName as string, operation: 'findByIds' }); - if (ids.length == 0) return []; // shortcircuit if no IDs are provided - - const rows = await nativeQuery({ sql: `select * from "${camelCaseToSnakeCase(entityName as string)}" where "id" = any($1)`, params: [ ids ] }); - if (rows.length != ids.length) throw new OrmError('ORM-1202', { entity: entityName as string, operation: 'findByIds' }); - - // Update the loaded entities cache - updateCacheWithNewEntities(entityName, rows); - - return rows.map(r => prepEntity(entityName, r)) as OutputType>[]; - } - - /** - * Fetches multiple entities from the database - */ - async function findMany>(entityName: N, options?: FindManyOptions>): Promise>[]> { - if (transactionControls == null) throw new OrmError('ORM-1000', { entity: entityName as string, operation: 'findMany' }); - - const empty: SqlStatement = { sql: '', params: [] }; - const sqlStatement = mergeSql( - { sql: `select * from "${camelCaseToSnakeCase(entityName as string)}"`, params: [] }, - options?.where != null ? mergeSql({ sql: 'where', params: [] }, whereClauseToSql(options.where)) : empty, - options?.orderBy != null ? mergeSql({ sql: 'order by', params: [] }, orderByToSql(options.orderBy)) : empty, - options != null && 'offset' in options && options.offset != null ? { sql: 'offset $1', params: [ options.offset ] } : empty, - options != null && 'limit' in options && options.limit != null ? { sql: 'limit $1', params: [ options.limit ] } : empty, - ); - const rows = await nativeQuery(sqlStatement); - - // Update loaded entities cache - updateCacheWithNewEntities(entityName, rows); - - // Output the fetched entities as full entity objects - return rows.map(r => prepEntity(entityName, r)) as OutputType>[]; - } - - /** - * Counts the amount of entities filtered by the where clause - * Has no orderby, offset, or limit, because none of those affect the count() of the full query - */ - async function count>(entityName: N, options?: { where?: WhereClause> }): Promise { - if (transactionControls == null) throw new OrmError('ORM-1000', { entity: entityName as string, operation: 'count' }); - - const empty: SqlStatement = { sql: '', params: [] }; - const sqlStatement = mergeSql( - { sql: `select count("id") as "amount" from "${camelCaseToSnakeCase(entityName as string)}"`, params: [] }, - options?.where != null ? mergeSql({ sql: 'where', params: [] }, whereClauseToSql(options.where)) : empty, - ); - const rows = await nativeQuery(sqlStatement); - return rows[0]['amount']; - } - - /** - * Finds both a limited amount of entities and the total amount of entities that match the where clause - * This can be useful in paginated contexts= - */ - async function findManyAndCount>(entityName: N, options?: FindManyOptions>): Promise<{ results: OutputType>[], total: number }> { - const results = await findMany(entityName, options); - const total = await count(entityName, options == null ? undefined : { where: options?.where }); - return { results, total }; - } - + /** * Fetches a relation * This will actually look at all entities in the local cache and do a select for all of them, meaning that after the first call to this @@ -673,6 +474,104 @@ export class Farstorm extends EventEmitt } }; + /** + * User facing function, fetches a single entity from the database + * This function will throw if the entity is not found + */ + async function findOne>(entityName: N, id: string): Promise>> { + if (transactionControls == null) throw new OrmError('ORM-1000', { entity: entityName as string, operation: 'findOne' }); + + const rows = await nativeQuery({ sql: `select * from "${camelCaseToSnakeCase(entityName as string)}" where "id" = $1`, params: [ id ] }); + if (rows == null || rows.length == 0) throw new OrmError('ORM-1200', { entity: entityName as string, operation: 'findOne' }); + if (rows.length > 1) throw new OrmError('ORM-1201', { entity: entityName as string, operation: 'findOne' }); + + // Update the loaded entities cache + updateCacheWithNewEntities(entityName, rows); + + return createOutputTypeFromRawSqlType(entityName, rows[0]) as any; + } + + /** + * Fetches a single entity from the database, but returns null if the entity is not found + */ + async function findOneOrNull>(entityName: N, id: string): Promise> | null> { + if (transactionControls == null) throw new OrmError('ORM-1000', { entity: entityName as string, operation: 'findOneOrNull' }); + + const rows = await nativeQuery({ sql: `select * from "${camelCaseToSnakeCase(entityName as string)}" where "id" = $1`, params: [ id ] }); + if (rows == null || rows.length == 0) return null; + if (rows.length > 1) throw new OrmError('ORM-1201', { entity: entityName as string, operation: 'findOneOrNull' }); + + // Update the loaded entities cache + updateCacheWithNewEntities(entityName, rows); + + return createOutputTypeFromRawSqlType(entityName, rows[0]) as OutputType>; + } + + /** + * Fetches a list of entities from the database by their IDs + */ + async function findByIds>(entityName: N, ids: string[]) { + if (transactionControls == null) throw new OrmError('ORM-1000', { entity: entityName as string, operation: 'findByIds' }); + if (ids.length == 0) return []; // shortcircuit if no IDs are provided + + const rows = await nativeQuery({ sql: `select * from "${camelCaseToSnakeCase(entityName as string)}" where "id" = any($1)`, params: [ ids ] }); + if (rows.length != ids.length) throw new OrmError('ORM-1202', { entity: entityName as string, operation: 'findByIds' }); + + // Update the loaded entities cache + updateCacheWithNewEntities(entityName, rows); + + return rows.map(r => createOutputTypeFromRawSqlType(entityName, r)) as OutputType>[]; + } + + /** + * Fetches multiple entities from the database + */ + async function findMany>(entityName: N, options?: FindManyOptions>): Promise>[]> { + if (transactionControls == null) throw new OrmError('ORM-1000', { entity: entityName as string, operation: 'findMany' }); + + const empty: SqlStatement = { sql: '', params: [] }; + const sqlStatement = mergeSql( + { sql: `select * from "${camelCaseToSnakeCase(entityName as string)}"`, params: [] }, + options?.where != null ? mergeSql({ sql: 'where', params: [] }, whereClauseToSql(options.where)) : empty, + options?.orderBy != null ? mergeSql({ sql: 'order by', params: [] }, orderByToSql(options.orderBy)) : empty, + options != null && 'offset' in options && options.offset != null ? { sql: 'offset $1', params: [ options.offset ] } : empty, + options != null && 'limit' in options && options.limit != null ? { sql: 'limit $1', params: [ options.limit ] } : empty, + ); + const rows = await nativeQuery(sqlStatement); + + // Update loaded entities cache + updateCacheWithNewEntities(entityName, rows); + + // Output the fetched entities as full entity objects + return rows.map(r => createOutputTypeFromRawSqlType(entityName, r)) as OutputType>[]; + } + + /** + * Counts the amount of entities filtered by the where clause + * Has no orderby, offset, or limit, because none of those affect the count() of the full query + */ + async function count>(entityName: N, options?: { where?: WhereClause> }): Promise { + if (transactionControls == null) throw new OrmError('ORM-1000', { entity: entityName as string, operation: 'count' }); + + const empty: SqlStatement = { sql: '', params: [] }; + const sqlStatement = mergeSql( + { sql: `select count("id") as "amount" from "${camelCaseToSnakeCase(entityName as string)}"`, params: [] }, + options?.where != null ? mergeSql({ sql: 'where', params: [] }, whereClauseToSql(options.where)) : empty, + ); + const rows = await nativeQuery(sqlStatement); + return rows[0]['amount']; + } + + /** + * Finds both a limited amount of entities and the total amount of entities that match the where clause + * This can be useful in paginated contexts= + */ + async function findManyAndCount>(entityName: N, options?: FindManyOptions>): Promise<{ results: OutputType>[], total: number }> { + const results = await findMany(entityName, options); + const total = await count(entityName, options == null ? undefined : { where: options?.where }); + return { results, total }; + } + /** * Executes a native query against the database and gives back the result as an array of objects */ @@ -733,7 +632,7 @@ export class Farstorm extends EventEmitt // If the relation is a getter for fetching the relation, we should be careful and try to see if the dev has actually fetched and modified the relation before saving // Right now we assume they haven't, but we should probably check the local cache, see if this relation was ever fetched, and if so try and see if the values were modified - if (_isOrmRelationGetter(e, relation)) continue; + if (isOrmRelationGetter(e, relation)) continue; // If the relation is nullable and the value is null, we should set the field to null const resolvedRelation: any = await e[relation]; @@ -748,7 +647,7 @@ export class Farstorm extends EventEmitt // If the relation is a getter for fetching the relation, we should be careful and try to see if the dev has actually fetched and modified the relation before saving // Right now we assume they haven't, but we should probably check the local cache, see if this relation was ever fetched, and if so try and see if the values were modified - if (_isOrmRelationGetter(e, relation)) continue; + if (isOrmRelationGetter(e, relation)) continue; // If the relation is nullable and the value is null, we should set the field to null const resolvedRelation: any = await e[relation]; @@ -932,7 +831,7 @@ export class Farstorm extends EventEmitt }); } - return rows.map(row => prepEntity(entityName, row)) as OutputType>[]; + return rows.map(row => createOutputTypeFromRawSqlType(entityName, row)) as OutputType>[]; }; function prepValueForPgFormat(input: any): any { @@ -1078,7 +977,10 @@ export class Farstorm extends EventEmitt } } +export { ChangeTracker } from './transaction/ChangeTracker.js'; +export { defineAutogeneratedField, defineCustomField, defineEntity, defineField, defineIdField } from './entities/BaseEntity.js'; export { sql } from './helpers/sql.js'; export { unwrap, unwrapAll } from './helpers/unwrap.js'; -export { defineEntity, defineIdField, defineField, defineCustomField, defineAutogeneratedField } from './entities/BaseEntity.js'; -export { ChangeTracker } from './ChangeTracker.js'; +export { InputType } from './types/InputType.js'; +export { OutputType } from './types/OutputType.js'; +export { RawSqlType } from './types/RawSqlType.js'; \ No newline at end of file diff --git a/src/relations/ormRelationGetter.ts b/src/relations/ormRelationGetter.ts new file mode 100644 index 0000000..6f383c6 --- /dev/null +++ b/src/relations/ormRelationGetter.ts @@ -0,0 +1,9 @@ +// Magic getter constant +// This is used on custom property getters to mark them as ORM relation getters +export const ormRelationGetter = Symbol('ormRelationGetter'); + +export function isOrmRelationGetter(object: any, property: string) { + const getter = Object.getOwnPropertyDescriptor(object, property)?.get; + if (getter == null) return false; + return ormRelationGetter in getter && getter[ormRelationGetter] != null && getter[ormRelationGetter] == true; +} \ No newline at end of file diff --git a/src/validateSchema.ts b/src/tools/validateSchema.ts similarity index 95% rename from src/validateSchema.ts rename to src/tools/validateSchema.ts index 71f4fbc..d679833 100644 --- a/src/validateSchema.ts +++ b/src/tools/validateSchema.ts @@ -1,8 +1,8 @@ -import { sql, SqlStatement } from './helpers/sql.js'; -import { SchemaValidationError } from './errors/SchemaValidationError.js'; -import { camelCaseToSnakeCase, suffixId } from './util/strings.js'; -import { SchemaValidationResult } from './main.js'; -import { BaseEntity } from './entities/BaseEntity.js'; +import { sql, SqlStatement } from '../helpers/sql.js'; +import { SchemaValidationError } from '../errors/SchemaValidationError.js'; +import { camelCaseToSnakeCase, suffixId } from '../util/strings.js'; +import { SchemaValidationResult } from '../main.js'; +import { BaseEntity } from '../entities/BaseEntity.js'; /** * Running this function will check the entity definitions against the database schema diff --git a/src/ChangeTracker.ts b/src/transaction/ChangeTracker.ts similarity index 100% rename from src/ChangeTracker.ts rename to src/transaction/ChangeTracker.ts diff --git a/src/EntityCache.ts b/src/transaction/EntityCache.ts similarity index 100% rename from src/EntityCache.ts rename to src/transaction/EntityCache.ts diff --git a/src/RelationCache.ts b/src/transaction/RelationCache.ts similarity index 100% rename from src/RelationCache.ts rename to src/transaction/RelationCache.ts diff --git a/src/types/BaseEntityDefinitions.ts b/src/types/BaseEntityDefinitions.ts new file mode 100644 index 0000000..4ab7f34 --- /dev/null +++ b/src/types/BaseEntityDefinitions.ts @@ -0,0 +1,3 @@ +import { BaseEntity } from "../entities/BaseEntity.js"; + +export type BaseEntityDefinitions = Record; \ No newline at end of file diff --git a/src/types/EntityTypes.ts b/src/types/EntityTypes.ts new file mode 100644 index 0000000..ee249e5 --- /dev/null +++ b/src/types/EntityTypes.ts @@ -0,0 +1,6 @@ +import { BaseEntityDefinitions } from "./BaseEntityDefinitions.js"; + +// Entity helper types +export type EntityName = keyof ED; +export type EntityDefinition = ED[EntityName]; +export type EntityByName> = ED[K]; \ No newline at end of file diff --git a/src/types/EvalGeneric.ts b/src/types/EvalGeneric.ts new file mode 100644 index 0000000..3925f06 --- /dev/null +++ b/src/types/EvalGeneric.ts @@ -0,0 +1,7 @@ +// This type is an identity type: the output is identical to the input, except it forces the TypeScript compiler to evaluate the type +// Collapsing the type is useful both for performance reasons (it makes TypeScript horribly slow otherwise in some scenarios) +// and it has the added benefit of making the types much more readable in error messages and IDE inlay hints +// type EvalGeneric = X extends infer C ? { [K in keyof C]: C[K] } : never; +export type EvalGeneric = unknown extends X + ? X + : (X extends infer C ? { [K in keyof C]: C[K] } : never); \ No newline at end of file diff --git a/src/types/FieldTypes.ts b/src/types/FieldTypes.ts new file mode 100644 index 0000000..fb504e6 --- /dev/null +++ b/src/types/FieldTypes.ts @@ -0,0 +1,10 @@ +import { BaseEntityDefinitions } from "./BaseEntityDefinitions.js"; +import { EntityDefinition } from "./EntityTypes.js"; +import { IsNullable } from "./IsNullable.js"; + +// Helper types to get field properties for a specific entity +export type Fields> = E['fields']; +export type FieldNames> = keyof Fields; +export type Field, N extends FieldNames> = Fields[N]; +export type FieldType, N extends FieldNames> = ReturnType['toType']>; +export type FieldNullNever, N extends FieldNames> = IsNullable['nullableOnOutput']>; diff --git a/src/types/InputType.ts b/src/types/InputType.ts new file mode 100644 index 0000000..f56c2d3 --- /dev/null +++ b/src/types/InputType.ts @@ -0,0 +1,28 @@ +import { BaseEntityDefinitions } from "./BaseEntityDefinitions.js"; +import { EntityByName, EntityDefinition } from "./EntityTypes.js"; +import { EvalGeneric } from "./EvalGeneric.js"; +import { FieldType } from "./FieldTypes.js"; +import { OutputTypeRef } from "./OutputType.js"; +import { WithOptionalId } from "./WithOptionalId.js"; + +// Define input type for save functions +type InputTypeFields> = + { -readonly [N in keyof E['fields'] as E['fields'][N]['nullableOnInput'] extends true ? never : N]: FieldType } // mandatory properties + & { -readonly [N in keyof E['fields'] as E['fields'][N]['nullableOnInput'] extends true ? N : never]?: FieldType | null | undefined }; // optionals + +type InputTypeOneToOneOwned> = + { -readonly [N in keyof E['oneToOneOwned'] as E['oneToOneOwned'][N]['nullable'] extends true ? never : N]: OutputTypeRef> } // mandatory properties + & { -readonly [N in keyof E['oneToOneOwned'] as E['oneToOneOwned'][N]['nullable'] extends true ? N : never]?: OutputTypeRef> | null | undefined }; // optionals + +type InputTypeManyToOne> = + { -readonly [N in keyof E['manyToOne'] as E['manyToOne'][N]['nullable'] extends true ? never : N]: OutputTypeRef> } // mandatory properties + & { -readonly [N in keyof E['manyToOne'] as E['manyToOne'][N]['nullable'] extends true ? N : never]?: OutputTypeRef> | null | undefined }; // optionals + +export type InputTypeWithId> = + InputTypeFields & InputTypeOneToOneOwned & InputTypeManyToOne & { + -readonly [N in keyof E['oneToOneInverse']]?: any /* Allow feeding something, but we don't care what exactly since we won't save it. We could narrow this to identical to OutputType<> */ + } & { + -readonly [N in keyof E['oneToMany']]?: any /* Allow feeding something, but we don't care what exactly since we won't save it. We could narrow this to identical to OutputType<> */ + }; + +export type InputType> = EvalGeneric>>; \ No newline at end of file diff --git a/src/types/IsNullable.ts b/src/types/IsNullable.ts new file mode 100644 index 0000000..6b48f4c --- /dev/null +++ b/src/types/IsNullable.ts @@ -0,0 +1 @@ +export type IsNullable = T extends true ? null : never; \ No newline at end of file diff --git a/src/types/OutputType.ts b/src/types/OutputType.ts new file mode 100644 index 0000000..c760e4e --- /dev/null +++ b/src/types/OutputType.ts @@ -0,0 +1,22 @@ +import { BaseEntityDefinitions } from "./BaseEntityDefinitions.js"; +import { EntityByName, EntityDefinition } from "./EntityTypes.js"; +import { EvalGeneric } from "./EvalGeneric.js"; +import { FieldNullNever, FieldType } from "./FieldTypes.js"; +import { IsNullable } from "./IsNullable.js"; + +// Prep the output type for a specific entity +// Defines a type for every field on a given entity, including correctly nullability setting +// Defines the types for all relations, which will something like Promise or Promise, depending on the relation type, with correct nullability +export type OutputType> = EvalGeneric<{ + -readonly [N in keyof E['fields']]: FieldType | FieldNullNever +} & { + -readonly [N in keyof E['oneToOneOwned']]: Promise>> | IsNullable +} & { + -readonly [N in keyof E['oneToOneInverse']]: Promise> | IsNullable> +} & { + -readonly [N in keyof E['manyToOne']]: Promise>> | IsNullable +} & { + [N in keyof E['oneToMany']]: Promise>[]> +}>; + +export type OutputTypeRef> = Promise> | OutputType; \ No newline at end of file diff --git a/src/types/RawSqlType.ts b/src/types/RawSqlType.ts new file mode 100644 index 0000000..b0343e7 --- /dev/null +++ b/src/types/RawSqlType.ts @@ -0,0 +1,17 @@ +import { CamelToSnakeCase, IdSuffixed } from "../util/strings.js"; +import { BaseEntityDefinitions } from "./BaseEntityDefinitions.js"; +import { EntityDefinition } from "./EntityTypes.js"; +import { IsNullable } from "./IsNullable.js"; + +// Construct the RawSqlType, which is a type definition that encompasses what is returned from a SQL-query +// Currently any types for OneToMany relations that aren't defined as a ManyToOne on the other side are missing +// (e.g if B defines a OneToMany to A, then A will not have a field for this relation, even though in reality there will be a b_id column in the table for A) +// The clunky N extends string stuff is to ensure TypeScript does not get confused. Even though the keys of FieldNames<> and such are always strings, +// for some reason TS tries to use the default key type (symbol | number | string) instead of the more narrow string we know it to be. +export type RawSqlType> = { + -readonly [N in keyof E['fields'] as CamelToSnakeCase>]: ReturnType | IsNullable +} & { + -readonly [N in keyof E['oneToOneOwned'] as IdSuffixed>>]: number | IsNullable +} & { + -readonly [N in keyof E['manyToOne'] as IdSuffixed>>]: number | IsNullable +}; \ No newline at end of file diff --git a/src/types/WithOptionalId.ts b/src/types/WithOptionalId.ts new file mode 100644 index 0000000..e37181b --- /dev/null +++ b/src/types/WithOptionalId.ts @@ -0,0 +1 @@ +export type WithOptionalId = Omit & { id?: string | null }; \ No newline at end of file diff --git a/src/util/keys.ts b/src/util/keys.ts new file mode 100644 index 0000000..d2bbd84 --- /dev/null +++ b/src/util/keys.ts @@ -0,0 +1,3 @@ +export function strictKeysOfObject(input: T): Array { + return Object.keys(input) as any; +} \ No newline at end of file diff --git a/tests/testHelpers.ts b/tests/testHelpers.ts index 20db5ee..a982ad7 100644 --- a/tests/testHelpers.ts +++ b/tests/testHelpers.ts @@ -1,9 +1,9 @@ -import { _isOrmRelationGetter } from '../src/main'; +import { isOrmRelationGetter } from '../src/relations/ormRelationGetter'; export function hideRelations(input: T): T { const output = {} as T; for (const key in input) { - if (!_isOrmRelationGetter(input, key)) { + if (!isOrmRelationGetter(input, key)) { output[key] = input[key]; } }