diff --git a/.changeset/fair-knives-notice.md b/.changeset/fair-knives-notice.md new file mode 100644 index 0000000..ca2932f --- /dev/null +++ b/.changeset/fair-knives-notice.md @@ -0,0 +1,5 @@ +--- +"farstorm": minor +--- + +Add explicit way to set nullability by providing 'NULLABLE' and 'NOT NULL' instead of the hard to read true/false on the nullable column diff --git a/eslint.config.js b/eslint.config.js index 160ae94..fa8a660 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -118,7 +118,7 @@ export default defineConfig([ { "no-unsafe-optional-chaining": "error", "no-unused-private-class-members": "warn", "no-useless-backreference": "warn", - "require-atomic-updates": "warn", + // "require-atomic-updates": "warn", "use-isnan": "error", "valid-typeof": "error", "eqeqeq": "off", diff --git a/src/entities/BaseEntity.ts b/src/entities/BaseEntity.ts index 9598b07..c0c5c47 100644 --- a/src/entities/BaseEntity.ts +++ b/src/entities/BaseEntity.ts @@ -1,3 +1,6 @@ +import { Nullable } from "./Nullable.js"; + + export type BaseEntity = { fields: Record> & { 'id': BaseIdField }, oneToOneOwned: Record, @@ -15,8 +18,8 @@ export type NonStrictBaseEntity = { }; export type BaseIdField = { - nullableOnInput: true, - nullableOnOutput: false, + nullableOnInput: 'NULLABLE', + nullableOnOutput: 'NOT NULL', toType: (sqlInput: number) => string, fromType: (tsInput: string) => number, }; @@ -24,13 +27,13 @@ export type BaseIdField = { export type BaseFieldCustomType = { toType: (sqlInput: SQL) => TS, fromType: (tsInput: TS) => SQL, - nullableOnInput: boolean, - nullableOnOutput: boolean, + nullableOnInput: Nullable, + nullableOnOutput: Nullable, }; -type OneToOneRelationOwned = { entity: string, nullable: boolean, inverse?: never }; -type ManyToOneRelation = { entity: string, nullable: boolean, inverse?: never }; -type OneToOneRelationInverse = { entity: string, inverse: string, nullable: boolean }; +type OneToOneRelationOwned = { entity: string, nullable: Nullable, inverse?: never }; +type ManyToOneRelation = { entity: string, nullable: Nullable, inverse?: never }; +type OneToOneRelationInverse = { entity: string, inverse: string, nullable: Nullable }; type OneToManyRelation = { entity: string, inverse: string, nullable?: never }; export function defineEntity(entity: T): { @@ -39,9 +42,11 @@ export function defineEntity(entity: T): { oneToOneInverse: NonNullable, manyToOne: NonNullable, oneToMany: NonNullable, + relations?: never, } { // It would be really nice to encode this in the type system, but that is a major PITA if ('relations' in entity && entity.relations != null) throw new Error('Entity should not have a `relations` property, use the appropriate relation type keys instead'); + return { ...entity, fields: entity.fields, @@ -49,7 +54,6 @@ export function defineEntity(entity: T): { oneToOneInverse: entity.oneToOneInverse ?? {}, manyToOne: entity.manyToOne ?? {}, oneToMany: entity.oneToMany ?? {}, - // manyToMany: entity.relations, // TODO } as const; } @@ -61,18 +65,18 @@ const defaultFieldTypes = { Json: defineCustomField(false, (x: any) => x, (x: any) => x), } as const; -export function defineField(type: T, nullable: Null): Omit & { nullableOnInput: Null, nullableOnOutput: Null } { +export function defineField(type: T, nullable: Null): Omit & { nullableOnInput: Null, nullableOnOutput: Null } { return { ...defaultFieldTypes[type], nullableOnInput: nullable, nullableOnOutput: nullable } as const; } export function defineAutogeneratedField(type: T): Omit & { nullableOnInput: true, nullableOnOutput: false } { - return { ...defaultFieldTypes[type], nullableOnInput: true, nullableOnOutput: false } as const; + return { ...defaultFieldTypes[type], nullableOnInput: 'NULLABLE', nullableOnOutput: 'NOT NULL' } as const; } -export function defineCustomField any, FromType extends (x: any) => any>(nullable: Null, toType: ToType, fromType: FromType) { +export function defineCustomField any, FromType extends (x: any) => any>(nullable: Null, toType: ToType, fromType: FromType) { return { nullableOnInput: nullable, nullableOnOutput: nullable, toType, fromType } as const; } export function defineIdField() { - return { nullableOnInput: true, nullableOnOutput: false, toType: (x: number) => x.toString(), fromType: (x: string) => Number(x) } as const; + return { nullableOnInput: 'NULLABLE', nullableOnOutput: 'NOT NULL', toType: (x: number) => x.toString(), fromType: (x: string) => Number(x) } as const; } \ No newline at end of file diff --git a/src/entities/Nullable.ts b/src/entities/Nullable.ts new file mode 100644 index 0000000..d307d58 --- /dev/null +++ b/src/entities/Nullable.ts @@ -0,0 +1,8 @@ +export type LegacyNullable = boolean; +export type Nullable = 'NULLABLE' | 'NOT NULL' | LegacyNullable; + +export function isNullable(nullable: Nullable) { + if (nullable == 'NULLABLE') return true; + if (nullable == 'NOT NULL') return false; + return !!nullable; +} \ No newline at end of file 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..f8ea0c5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,114 +1,29 @@ -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'; +import { isNullable } from './entities/Nullable.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 +43,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 +149,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 +212,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> @@ -329,7 +229,7 @@ export class Farstorm extends EventEmitt const relationRawFieldName = suffixId(camelCaseToSnakeCase(relationName as string)); if (result[relationRawFieldName] == null) { - if (!relation.nullable) throw new OrmError('ORM-1102', { entity: entityName as string, relation: relationName, operation: 'resolve-one-to-one-owned' }, queryStatistics.queries); + if (!isNullable(relation.nullable)) throw new OrmError('ORM-1102', { entity: entityName as string, relation: relationName, operation: 'resolve-one-to-one-owned' }, queryStatistics.queries); output[relationName] = null; continue; } @@ -344,10 +244,10 @@ export class Farstorm extends EventEmitt if (cached == null) throw new OrmError('ORM-1001', { entity: entityName as string, relation: relationName, operation: 'resolve-one-to-one-owned' }); const rawResult = localCache.get(relation.entity, result[relationRawFieldName]); - if (rawResult == null && !relation.nullable) { + if (rawResult == null && !isNullable(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 }); } }); @@ -359,7 +259,7 @@ export class Farstorm extends EventEmitt const relationRawFieldName = suffixId(camelCaseToSnakeCase(relationName as string)); if (result[relationRawFieldName] == null) { - if (!relation.nullable) throw new OrmError('ORM-1122', { entity: entityName as string, relation: relationName, operation: 'resolve-many-to-one' }, queryStatistics.queries); + if (!isNullable(relation.nullable)) throw new OrmError('ORM-1122', { entity: entityName as string, relation: relationName, operation: 'resolve-many-to-one' }, queryStatistics.queries); output[relationName] = null; continue; } @@ -374,10 +274,10 @@ export class Farstorm extends EventEmitt if (cached == null) throw new OrmError('ORM-1001', { entity: entityName as string, relation: relationName, operation: 'resolve-many-to-one' }); const rawResult = localCache.get(relation.entity, result[relationRawFieldName]); - if (rawResult == null && !relation.nullable) { + if (rawResult == null && !isNullable(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 }); } }); @@ -399,11 +299,11 @@ export class Farstorm extends EventEmitt const items = (cached.inverseMap[result.id as string] ?? []).map(item => localCache.get(relation.entity, item.id)).filter(x => x != null); if (items.length > 1) { throw new OrmError('ORM-1100', { entity: entityName as string, relation: relationName, operation: 'resolve-one-to-one-inverse' }, queryStatistics.queries); - } else if (items.length == 0 && !relation.nullable) { + } else if (items.length == 0 && !isNullable(relation.nullable)) { 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 +326,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 +334,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 +475,115 @@ 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 idsSet = new Set(ids); + + const rows = await nativeQuery({ sql: `select * from "${camelCaseToSnakeCase(entityName as string)}" where "id" = any($1)`, params: [ ids ] }); + if (rows.length != idsSet.size) throw new OrmError('ORM-1202', { entity: entityName as string, operation: 'findByIds' }); + + // Update the loaded entities cache + updateCacheWithNewEntities(entityName, rows); + + // Populate hash map for quick lookups + const rowsById: Record = {}; + for (const row of rows) { + rowsById[row.id] = row; + } + + // Get rows in order of ids passed in + const orderedRows = ids.map(id => rowsById[id]); + + return orderedRows.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 */ @@ -718,7 +629,7 @@ export class Farstorm extends EventEmitt const fieldDefinition = entityDefinition.fields[field]; if (field == 'id' && e[field] == null) continue; // We don't want to set the ID to null - if (e[field] == null && !entityDefinition.fields[field].nullableOnInput) { + if (e[field] == null && !isNullable(entityDefinition.fields[field].nullableOnInput)) { throw new OrmError('ORM-1301', { entity: entityName as string, field, operation: 'saveMany' }, queryStatistics.queries); } @@ -733,11 +644,11 @@ 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]; - if (!relationDefinition.nullable && resolvedRelation == null) throw new OrmError('ORM-1302', { entity: entityName as string, relation, operation: 'saveMany' }, queryStatistics.queries); + if (!isNullable(relationDefinition.nullable) && resolvedRelation == null) throw new OrmError('ORM-1302', { entity: entityName as string, relation, operation: 'saveMany' }, queryStatistics.queries); if (resolvedRelation != null && resolvedRelation.id == null) throw new OrmError('ORM-1303', { entity: entityName as string, relation, operation: 'saveMany' }, queryStatistics.queries); rawEntityFieldsToWrite[relationRawFieldName] = resolvedRelation?.id ?? null; } @@ -748,11 +659,11 @@ 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]; - if (!relationDefinition.nullable && resolvedRelation == null) throw new OrmError('ORM-1304', { entity: entityName as string, relation, operation: 'saveMany' }, queryStatistics.queries); + if (!isNullable(relationDefinition.nullable) && resolvedRelation == null) throw new OrmError('ORM-1304', { entity: entityName as string, relation, operation: 'saveMany' }, queryStatistics.queries); if (resolvedRelation != null && resolvedRelation.id == null) throw new OrmError('ORM-1305', { entity: entityName as string, relation, operation: 'saveMany' }, queryStatistics.queries); rawEntityFieldsToWrite[relationRawFieldName] = resolvedRelation?.id ?? null; } @@ -769,7 +680,7 @@ export class Farstorm extends EventEmitt if (entitiesToInsert.length > 0) { // Generate field names to insert, leaving out fields that are nullable on input and are null for the entire batch const rawFields = Object.keys(entitiesToInsert[0]) - .filter(rawFieldName => entityDefinition.fields[snakeCaseToCamelCase(rawFieldName)] == null || !entityDefinition.fields[snakeCaseToCamelCase(rawFieldName)].nullableOnInput || entitiesToInsert.some(e => e[rawFieldName] != null)); + .filter(rawFieldName => entityDefinition.fields[snakeCaseToCamelCase(rawFieldName)] == null || !isNullable(entityDefinition.fields[snakeCaseToCamelCase(rawFieldName)].nullableOnInput) || entitiesToInsert.some(e => e[rawFieldName] != null)); let insertResult; if (rawFields.length > 0) { @@ -932,7 +843,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 +989,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..7d05a20 --- /dev/null +++ b/src/types/IsNullable.ts @@ -0,0 +1,3 @@ +import { Nullable } from "../entities/Nullable.js"; + +export type IsNullable = T extends true | 'NULLABLE' ? 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/pg-backend/findByIds.test.ts b/tests/pg-backend/findByIds.test.ts index 5521733..c140561 100644 --- a/tests/pg-backend/findByIds.test.ts +++ b/tests/pg-backend/findByIds.test.ts @@ -75,5 +75,40 @@ describe('Postgres: findByIds', () => { const todoItems = await findByIds('TodoItem', []); expect(todoItems).toEqual([]); }); + await cleanup(); + }); + + it('should preserve input order when finding by IDs in ascending order', async () => { + const { db, cleanup } = await setup(); + await db.inTransaction(async ({ findByIds }) => { + const todoItems = await findByIds('TodoItem', [ '1', '2' ]); + expect(todoItems).toHaveLength(2); + expect(todoItems[0].id).toBe('1'); + expect(todoItems[1].id).toBe('2'); + }); + await cleanup(); + }); + + it('should preserve input order when finding by IDs in descending order', async () => { + const { db, cleanup } = await setup(); + await db.inTransaction(async ({ findByIds }) => { + const todoItems = await findByIds('TodoItem', [ '2', '1' ]); + expect(todoItems).toHaveLength(2); + expect(todoItems[0].id).toBe('2'); + expect(todoItems[1].id).toBe('1'); + }); + await cleanup(); + }); + + it('should preserve input order with duplicate IDs', async () => { + const { db, cleanup } = await setup(); + await db.inTransaction(async ({ findByIds }) => { + const todoItems = await findByIds('TodoItem', [ '1', '2', '1' ]); + expect(todoItems).toHaveLength(3); + expect(todoItems[0].id).toBe('1'); + expect(todoItems[1].id).toBe('2'); + expect(todoItems[2].id).toBe('1'); + }); + await cleanup(); }); }); 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]; } }