mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
89f3055a29
The link table keyed by the trip id made both entities a one-to-one, and the test typecheck failed with TS2589 in the itinerary factory. It now holds its own id with trip_id UNIQUE, as trip_invite_tokens does, and its members hang off link_id. The factory error was not this table's alone: any further relation on trips, journeys or users tipped the same check. insertRow and createRow now take a caller's overrides as their own argument instead of a spread checked against RequiredEntityData, so a new relation no longer breaks the itinerary and tour factories. The suites that hand-build TripMembersService get a journalLinksStub.
204 lines
7.6 KiB
TypeScript
204 lines
7.6 KiB
TypeScript
import { toRow } from '../../../src/db/repositories/_shared/rows';
|
|
import { inContext, type FactoryOrm } from './context';
|
|
import type {
|
|
EntityClass,
|
|
EntityManager,
|
|
EntityData,
|
|
EntityDTO,
|
|
FilterQuery,
|
|
FindOptions,
|
|
Primary,
|
|
RequiredEntityData,
|
|
UpsertOptions,
|
|
} from '@mikro-orm/core';
|
|
|
|
/**
|
|
* Typed row access for tests, through MikroORM instead of raw SQL.
|
|
*
|
|
* These are the generic building blocks the domain factories next to this
|
|
* file are made of, and the readers a test uses to check what a request left
|
|
* behind. Every helper runs in a request context of its own (`inContext`), so
|
|
* it works the same against the app's ORM and a `createTestOrm()` one.
|
|
*
|
|
* Writes are native statements (`em.insert`, `em.nativeUpdate`,
|
|
* `em.nativeDelete`), the same calls the repositories build on: one INSERT
|
|
* with exactly the columns given, so a column left out takes its database
|
|
* default, as it did in the raw `INSERT` these replace. Reads skip the
|
|
* identity map and come back as plain rows (`toRow`), the shape a
|
|
* `SELECT *` returned.
|
|
*/
|
|
|
|
/**
|
|
* Inserts one row and returns its primary key (the autoincrement id for most tables).
|
|
*
|
|
* A factory hands its caller's `overrides` in as their own argument rather than
|
|
* spread into `data`: checking a spread `EntityData` against `RequiredEntityData`
|
|
* expands every relation the entity reaches, and for the itinerary and tour rows
|
|
* (day to trip, place to tour) that took tsc past its instantiation depth (TS2589).
|
|
*/
|
|
export function insertRow<T extends object>(
|
|
orm: FactoryOrm,
|
|
entity: EntityClass<T>,
|
|
data: RequiredEntityData<T>,
|
|
overrides?: EntityData<T>,
|
|
): Promise<Primary<T>> {
|
|
const row = (overrides ? { ...data, ...overrides } : data) as RequiredEntityData<T>;
|
|
// async, so a refused column rejects the returned promise rather than
|
|
// throwing before the caller has one to await.
|
|
return inContext(orm, async (em) => {
|
|
assertWritable(em, entity, row);
|
|
return em.insert(entity, row);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Inserts several rows and returns their primary keys in order. One INSERT per
|
|
* row: `em.insertMany` on plain data hands back only the last insert id (it
|
|
* returns `[insertId]` unless the driver reports `insertedIds`, which the SQL
|
|
* drivers never do), so a caller zipping ids with its rows would get one id.
|
|
*/
|
|
export function insertRows<T extends object>(
|
|
orm: FactoryOrm,
|
|
entity: EntityClass<T>,
|
|
data: RequiredEntityData<T>[],
|
|
): Promise<Primary<T>[]> {
|
|
if (data.length === 0) return Promise.resolve([]);
|
|
return inContext(orm, async (em) => {
|
|
for (const row of data) assertWritable(em, entity, row);
|
|
const ids: Primary<T>[] = [];
|
|
for (const row of data) ids.push(await em.insert(entity, row));
|
|
return ids;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Inserts one row into a table keyed by a single autoincrement `id` and reads
|
|
* it back, defaults and all. For a table with a composite or natural key use
|
|
* `insertRow` and then `findRow` with the key columns. `overrides` as for `insertRow`.
|
|
*/
|
|
export async function createRow<T extends { id: number }>(
|
|
orm: FactoryOrm,
|
|
entity: EntityClass<T>,
|
|
data: RequiredEntityData<T>,
|
|
overrides?: EntityData<T>,
|
|
): Promise<EntityDTO<T>> {
|
|
const id = await insertRow(orm, entity, data, overrides);
|
|
const row = await findRow(orm, entity, { id } as FilterQuery<T>);
|
|
if (!row) throw new Error(`createRow: ${entity.name} ${String(id)} is not there after its insert`);
|
|
return row;
|
|
}
|
|
|
|
/**
|
|
* The first row matching `where`, or null. An empty `where` asks for any row,
|
|
* which MikroORM's findOne refuses, so that one reads the first row instead.
|
|
*/
|
|
export function findRow<T extends object>(
|
|
orm: FactoryOrm,
|
|
entity: EntityClass<T>,
|
|
where: FilterQuery<T>,
|
|
): Promise<EntityDTO<T> | null> {
|
|
return inContext(orm, async (em) => {
|
|
const anyRow = typeof where === 'object' && where !== null && Object.keys(where).length === 0;
|
|
const found = anyRow
|
|
? (await em.find(entity, where, { limit: 1, disableIdentityMap: true }))[0]
|
|
: await em.findOne(entity, where, { disableIdentityMap: true });
|
|
return found ? toRow<T>(found) : null;
|
|
});
|
|
}
|
|
|
|
/** Every row matching `where` (all rows for `{}`), optionally ordered. */
|
|
export function findRows<T extends object>(
|
|
orm: FactoryOrm,
|
|
entity: EntityClass<T>,
|
|
where: FilterQuery<T> = {} as FilterQuery<T>,
|
|
orderBy?: FindOptions<T>['orderBy'],
|
|
): Promise<EntityDTO<T>[]> {
|
|
return inContext(orm, async (em) => {
|
|
const found = await em.find(entity, where, { disableIdentityMap: true, orderBy });
|
|
return found.map((row) => toRow<T>(row));
|
|
});
|
|
}
|
|
|
|
/** How many rows match `where` (all rows for `{}`). */
|
|
export function countRows<T extends object>(
|
|
orm: FactoryOrm,
|
|
entity: EntityClass<T>,
|
|
where: FilterQuery<T> = {} as FilterQuery<T>,
|
|
): Promise<number> {
|
|
return inContext(orm, (em) => em.count(entity, where));
|
|
}
|
|
|
|
/** Writes `data` to every row matching `where` and returns how many it touched. */
|
|
export function updateRows<T extends object>(
|
|
orm: FactoryOrm,
|
|
entity: EntityClass<T>,
|
|
where: FilterQuery<T>,
|
|
data: EntityData<T>,
|
|
): Promise<number> {
|
|
return inContext(orm, async (em) => {
|
|
assertWritable(em, entity, data);
|
|
return em.nativeUpdate(entity, where, data);
|
|
});
|
|
}
|
|
|
|
/** Deletes every row matching `where` (all rows for `{}`) and returns how many it removed. */
|
|
export function deleteRows<T extends object>(
|
|
orm: FactoryOrm,
|
|
entity: EntityClass<T>,
|
|
where: FilterQuery<T> = {} as FilterQuery<T>,
|
|
): Promise<number> {
|
|
return inContext(orm, (em) => em.nativeDelete(entity, where));
|
|
}
|
|
|
|
/**
|
|
* Inserts the row, or overwrites the given columns when its key already
|
|
* exists: the `INSERT OR REPLACE` / `ON CONFLICT DO UPDATE` of the raw
|
|
* fixtures, for tables keyed by a natural key (`app_settings.key`, the
|
|
* `notification_channel_preferences` triple, an addon id). `mergeFields`
|
|
* narrows what an existing row gets overwritten with, like an
|
|
* `ON CONFLICT ... DO UPDATE SET enabled = excluded.enabled`.
|
|
*/
|
|
export function upsertRow<T extends object, Fields extends string = never>(
|
|
orm: FactoryOrm,
|
|
entity: EntityClass<T>,
|
|
data: EntityData<T>,
|
|
mergeFields?: UpsertOptions<T, Fields>['onConflictMergeFields'],
|
|
): Promise<void> {
|
|
return inContext(orm, async (em) => {
|
|
assertWritable(em, entity, data);
|
|
await em.upsert<T, Fields>(entity, data, mergeFields ? { onConflictMergeFields: mergeFields } : undefined);
|
|
});
|
|
}
|
|
|
|
/** Inserts the row unless its key already exists: the raw fixtures' `INSERT OR IGNORE`. */
|
|
export function insertRowIgnoringConflict<T extends object>(
|
|
orm: FactoryOrm,
|
|
entity: EntityClass<T>,
|
|
data: EntityData<T>,
|
|
): Promise<void> {
|
|
return inContext(orm, async (em) => {
|
|
assertWritable(em, entity, data);
|
|
await em.upsert(entity, data, { onConflictAction: 'ignore' });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Refuses a column the entity maps read-only. Most foreign keys appear twice
|
|
* on an entity: the relation (`trip`), which is written, and its `trip_id`
|
|
* twin, which is only read (`persist(false)`). MikroORM drops a value given
|
|
* for the twin without a word, so a fixture written as `{ trip_id: 3 }`
|
|
* would insert a row with no trip at all; this names the relation instead.
|
|
*/
|
|
export function assertWritable<T extends object>(em: EntityManager, entity: EntityClass<T>, data: object): void {
|
|
const meta = em.getMetadata().get(entity);
|
|
for (const key of Object.keys(data)) {
|
|
const prop = meta.props.find((p) => p.name === key);
|
|
if (!prop || prop.persist !== false) continue;
|
|
const column = prop.fieldNames?.[0];
|
|
const owner = meta.props.find((p) => p !== prop && p.persist !== false && column && p.fieldNames?.includes(column));
|
|
throw new Error(
|
|
`${entity.name}.${key} is read-only in the entity` + (owner ? `; write it through '${owner.name}'` : ''),
|
|
);
|
|
}
|
|
}
|