From 4a67be3f87b879d138eafa3d78bd790c52781590 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 22 Sep 2026 08:35:46 +0200 Subject: [PATCH] feat(db): primary-relation entities proven (BudgetCategoryOrder, VacayUserSettings) --- .../db/entities/BudgetCategoryOrder.entity.ts | 13 +- .../db/entities/VacayUserSettings.entity.ts | 25 +-- .../BudgetCategoryOrder.repository.ts | 4 + .../VacayUserSettings.repository.ts | 4 + .../entities/primary-relations.spike.test.ts | 174 ++++++++++++++++++ 5 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 server/src/db/repositories/BudgetCategoryOrder.repository.ts create mode 100644 server/src/db/repositories/VacayUserSettings.repository.ts create mode 100644 server/tests/unit/db/entities/primary-relations.spike.test.ts diff --git a/server/src/db/entities/BudgetCategoryOrder.entity.ts b/server/src/db/entities/BudgetCategoryOrder.entity.ts index f0558fc90..119676ca6 100644 --- a/server/src/db/entities/BudgetCategoryOrder.entity.ts +++ b/server/src/db/entities/BudgetCategoryOrder.entity.ts @@ -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; + trip_id!: number; category!: string; - sortOrder: number & Opt = 0; + sort_order: number & Opt = 0; } -export class BudgetCategoryOrderRepository extends EntityRepository {} - 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), }, }); diff --git a/server/src/db/entities/VacayUserSettings.entity.ts b/server/src/db/entities/VacayUserSettings.entity.ts index e1112bca7..93e8d1cfb 100644 --- a/server/src/db/entities/VacayUserSettings.entity.ts +++ b/server/src/db/entities/VacayUserSettings.entity.ts @@ -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 | 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 {} - 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(), }, }); diff --git a/server/src/db/repositories/BudgetCategoryOrder.repository.ts b/server/src/db/repositories/BudgetCategoryOrder.repository.ts new file mode 100644 index 000000000..0c21a9c19 --- /dev/null +++ b/server/src/db/repositories/BudgetCategoryOrder.repository.ts @@ -0,0 +1,4 @@ +import type { BudgetCategoryOrder } from '../entities/BudgetCategoryOrder.entity'; +import { EntityRepository } from '@mikro-orm/sql'; + +export class BudgetCategoryOrderRepository extends EntityRepository {} diff --git a/server/src/db/repositories/VacayUserSettings.repository.ts b/server/src/db/repositories/VacayUserSettings.repository.ts new file mode 100644 index 000000000..aed5745cf --- /dev/null +++ b/server/src/db/repositories/VacayUserSettings.repository.ts @@ -0,0 +1,4 @@ +import type { VacayUserSettings } from '../entities/VacayUserSettings.entity'; +import { EntityRepository } from '@mikro-orm/sql'; + +export class VacayUserSettingsRepository extends EntityRepository {} diff --git a/server/tests/unit/db/entities/primary-relations.spike.test.ts b/server/tests/unit/db/entities/primary-relations.spike.test.ts new file mode 100644 index 000000000..6b2fe9ff2 --- /dev/null +++ b/server/tests/unit/db/entities/primary-relations.spike.test.ts @@ -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 (`_`), independent of `.name()`. + class SpikeTarget { + code!: string; + } + const SpikeTargetSchema = defineEntity({ + class: SpikeTarget, + properties: { + code: p.text().primary(), + }, + }); + + class SpikeSourceName { + id?: number; + country!: Ref; + } + const SpikeSourceNameSchema = defineEntity({ + class: SpikeSourceName, + properties: { + id: p.integer().primary().autoincrement(), + country: () => p.manyToOne(SpikeTarget).ref().name('country'), + }, + }); + + class SpikeSourceJoinColumn { + id?: number; + country!: Ref; + } + 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 `_`. + 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); + }); +});