feat(db): primary-relation entities proven (BudgetCategoryOrder, VacayUserSettings)

This commit is contained in:
jubnl
2026-09-22 08:35:46 +02:00
parent 6d6b6932f4
commit 4a67be3f87
5 changed files with 202 additions and 18 deletions
@@ -1,21 +1,22 @@
import { type Opt, PrimaryKeyProp, type Ref, defineEntity, p, EntityRepository } from '@mikro-orm/core';
import { type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { BudgetCategoryOrderRepository } from '../repositories/BudgetCategoryOrder.repository';
import { Trips } from './Trips.entity';
export class BudgetCategoryOrder {
[PrimaryKeyProp]?: ['trip', 'category'];
trip!: Ref<Trips>;
trip_id!: number;
category!: string;
sortOrder: number & Opt = 0;
sort_order: number & Opt = 0;
}
export class BudgetCategoryOrderRepository extends EntityRepository<BudgetCategoryOrder> {}
export const BudgetCategoryOrderSchema = defineEntity({
class: BudgetCategoryOrder,
repository: () => BudgetCategoryOrderRepository,
properties: {
trip: () => p.manyToOne(Trips).primary().ref().deleteRule('cascade'),
trip: () => p.manyToOne(Trips).primary().ref().hidden().deleteRule('cascade'),
trip_id: p.integer().persist(false),
category: p.text().primary(),
sortOrder: p.integer(),
sort_order: p.integer().default(0),
},
});
@@ -1,25 +1,26 @@
import { type Opt, PrimaryKeyProp, type Ref, defineEntity, p, EntityRepository } from '@mikro-orm/core';
import { type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { VacayUserSettingsRepository } from '../repositories/VacayUserSettings.repository';
import { Users } from './Users.entity';
export class VacayUserSettings {
[PrimaryKeyProp]?: 'user';
user?: Ref<Users> | null;
yearType: string & Opt = 'calendar';
yearStartMonth: number & Opt = 1;
yearStartDay: number & Opt = 1;
hireDate?: string | null;
user_id!: number;
year_type: string & Opt = 'calendar';
year_start_month: number & Opt = 1;
year_start_day: number & Opt = 1;
hire_date?: string | null;
}
export class VacayUserSettingsRepository extends EntityRepository<VacayUserSettings> {}
export const VacayUserSettingsSchema = defineEntity({
class: VacayUserSettings,
repository: () => VacayUserSettingsRepository,
properties: {
user: () => p.oneToOne(Users).primary().ref().nullable(),
yearType: p.text(),
yearStartMonth: p.integer(),
yearStartDay: p.integer(),
hireDate: p.text().nullable(),
user: () => p.oneToOne(Users).primary().ref().hidden().nullable(),
user_id: p.integer().persist(false),
year_type: p.text().default('calendar'),
year_start_month: p.integer().default(1),
year_start_day: p.integer().default(1),
hire_date: p.text().nullable(),
},
});
@@ -0,0 +1,4 @@
import type { BudgetCategoryOrder } from '../entities/BudgetCategoryOrder.entity';
import { EntityRepository } from '@mikro-orm/sql';
export class BudgetCategoryOrderRepository extends EntityRepository<BudgetCategoryOrder> {}
@@ -0,0 +1,4 @@
import type { VacayUserSettings } from '../entities/VacayUserSettings.entity';
import { EntityRepository } from '@mikro-orm/sql';
export class VacayUserSettingsRepository extends EntityRepository<VacayUserSettings> {}
@@ -0,0 +1,174 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { defineEntity, p, wrap, type Ref } from '@mikro-orm/core';
import { MikroORM } from '@mikro-orm/sqlite';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTrip, createUser } from '../../../helpers/factories';
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
import { BudgetCategoryOrder } from '../../../../src/db/entities/BudgetCategoryOrder.entity';
import { VacayUserSettings } from '../../../../src/db/entities/VacayUserSettings.entity';
/**
* Plan 2 (ORM entity rewrite) Task 1 — the primary-relation spike.
*
* `BudgetCategoryOrder`/`VacayUserSettings` are the first entities to carry
* the Phase 0 shape (hidden relation + `persist(false)` scalar twin,
* snake_case scalars) on a relation that is (part of) the PRIMARY KEY. Before
* Task 3's bulk rewrite relies on that shape for the ~15 other
* primary-relation entities, this file pins that it actually works: a hidden
* PRIMARY relation still hydrates its twin, `findOne`/`find` accept the
* composite/one-to-one PK condition, and `toObject()` keeps emitting the twin
* column and omitting the relation — exactly as it does for a non-PRIMARY
* hidden relation (`DayNotes.day`/`DayNotes.trip`).
*
* Also pins the `.name()` vs `.joinColumn()` semantics Task 0 found on
* `SchoolHolidayRegions.country` (target PK is not `id`): `.name()` is an
* alias for `.fieldName()`, which a *relation* ignores when computing its FK
* column (the naming strategy still derives it from the property name + the
* target's PK) — only `.joinColumn()` overrides the column MikroORM emits.
*/
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeAll(async () => {
t = await createTestOrm(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
describe('primary-relation entities (Task 1 spike)', () => {
it('SPIKE-A: BudgetCategoryOrder — composite PK (trip relation + category text) round-trips create, findOne, find and toObject', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const created = t.em.create(BudgetCategoryOrder, { trip: trip.id, category: 'Food', sort_order: 2 });
await t.em.persist(created).flush();
t.em.clear();
const rawInsert = testDb
.prepare('SELECT * FROM budget_category_order WHERE trip_id = ? AND category = ?')
.get(trip.id, 'Food');
expect(rawInsert).toEqual({ trip_id: trip.id, category: 'Food', sort_order: 2 });
const found = await t.repo(BudgetCategoryOrder).findOne({ trip: trip.id, category: 'Food' });
expect(found).not.toBeNull();
t.em.clear();
const [hydrated] = await t.repo(BudgetCategoryOrder).find({ trip: trip.id });
expect(hydrated.trip_id).toBe(trip.id);
expect(hydrated.trip.isInitialized()).toBe(false);
expect(wrap(hydrated).toObject()).toStrictEqual(rawInsert);
});
it('SPIKE-B: VacayUserSettings — one-to-one PRIMARY relation (user) round-trips the same four facts', async () => {
const { user } = createUser(testDb);
const created = t.em.create(VacayUserSettings, {
user: user.id,
year_type: 'fiscal',
year_start_month: 4,
year_start_day: 1,
hire_date: '2020-01-01',
});
await t.em.persist(created).flush();
t.em.clear();
const rawInsert = testDb.prepare('SELECT * FROM vacay_user_settings WHERE user_id = ?').get(user.id);
expect(rawInsert).toEqual({
user_id: user.id,
year_type: 'fiscal',
year_start_month: 4,
year_start_day: 1,
hire_date: '2020-01-01',
});
const found = await t.repo(VacayUserSettings).findOne({ user: user.id });
expect(found).not.toBeNull();
t.em.clear();
const [hydrated] = await t.repo(VacayUserSettings).find({ user: user.id });
expect(hydrated.user_id).toBe(user.id);
expect(hydrated.user?.isInitialized()).toBe(false);
expect(wrap(hydrated).toObject()).toStrictEqual(rawInsert);
});
it("SPIKE-C: .name() sets the property's fieldName, but a relation's FK column is only overridden by .joinColumn()", async () => {
// A throwaway target whose PK is NOT `id` — the exact shape that made
// SchoolHolidayRegions.country_code (Task 0) a real bug: the naming
// strategy composes the FK column from the property name plus the
// target's PK name (`<prop>_<targetPk>`), independent of `.name()`.
class SpikeTarget {
code!: string;
}
const SpikeTargetSchema = defineEntity({
class: SpikeTarget,
properties: {
code: p.text().primary(),
},
});
class SpikeSourceName {
id?: number;
country!: Ref<SpikeTarget>;
}
const SpikeSourceNameSchema = defineEntity({
class: SpikeSourceName,
properties: {
id: p.integer().primary().autoincrement(),
country: () => p.manyToOne(SpikeTarget).ref().name('country'),
},
});
class SpikeSourceJoinColumn {
id?: number;
country!: Ref<SpikeTarget>;
}
const SpikeSourceJoinColumnSchema = defineEntity({
class: SpikeSourceJoinColumn,
properties: {
id: p.integer().primary().autoincrement(),
country: () => p.manyToOne(SpikeTarget).ref().joinColumn('country'),
},
});
const spikeOrm = await MikroORM.init({
entities: [SpikeTargetSchema, SpikeSourceNameSchema, SpikeSourceJoinColumnSchema],
dbName: ':memory:',
discovery: { warnWhenNoEntities: false },
});
try {
const namedMeta = spikeOrm.getMetadata().get(SpikeSourceName);
const joinColumnMeta = spikeOrm.getMetadata().get(SpikeSourceJoinColumn);
// `.name('country')` is an alias for `.fieldName('country')` (per
// node_modules/@mikro-orm/core/entity/defineEntity.d.ts) — it does not
// reach the FK-column computation for a relation, so the naming
// strategy still derives `<prop>_<targetPk>`.
expect(namedMeta.properties.country.fieldNames).toEqual(['country_code']);
// `.joinColumn('country')` overrides the computed FK column directly.
expect(joinColumnMeta.properties.country.fieldNames).toEqual(['country']);
} finally {
await spikeOrm.close(true);
}
});
it('SPIKE-D: a nullable text PRIMARY KEY (AppSettings.key) round-trips create → findOne unchanged, and toObject keeps the key', async () => {
const created = t.em.create(AppSettings, { key: 'spike_setting', value: 'on' });
await t.em.persist(created).flush();
t.em.clear();
const found = await t.repo(AppSettings).findOne({ key: 'spike_setting' });
expect(found?.key).toBe('spike_setting');
expect(found?.value).toBe('on');
const raw = testDb.prepare('SELECT * FROM app_settings WHERE key = ?').get('spike_setting');
expect(wrap(found).toObject()).toStrictEqual(raw);
});
});