From 570a6ede85d4e867161f3e9d065b3b945d8dae82 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 22 Sep 2026 15:46:46 +0200 Subject: [PATCH] fix(db): generator names relation properties in captured UNIQUE constraints --- server/scripts/generate-entities.ts | 76 ++++++++++++++----- .../entities/AssignmentParticipants.entity.ts | 2 +- .../db/entities/BudgetCategoryOrder.entity.ts | 2 +- .../db/entities/BudgetItemMembers.entity.ts | 2 +- .../db/entities/BudgetItemPayers.entity.ts | 2 +- .../entities/CollabMessageReactions.entity.ts | 2 +- .../src/db/entities/CollabPollVotes.entity.ts | 2 +- .../db/entities/CollectionMembers.entity.ts | 2 +- .../entities/CollectionPlaceRatings.entity.ts | 2 +- .../DawarichVisitSuggestions.entity.ts | 2 +- server/src/db/entities/Days.entity.ts | 2 +- .../db/entities/DocumentConnections.entity.ts | 2 +- .../entities/DocumentProviderFields.entity.ts | 2 +- server/src/db/entities/FileLinks.entity.ts | 6 +- .../src/db/entities/HiddenCountries.entity.ts | 2 +- .../src/db/entities/HiddenRegions.entity.ts | 2 +- .../src/db/entities/IdempotencyKeys.entity.ts | 2 +- .../db/entities/JourneyContributors.entity.ts | 2 +- .../db/entities/JourneyEntryPhotos.entity.ts | 2 +- .../src/db/entities/JourneyPhotos.entity.ts | 2 +- server/src/db/entities/JourneyTrips.entity.ts | 2 +- .../NotificationChannelPreferences.entity.ts | 2 +- .../src/db/entities/OauthConsents.entity.ts | 2 +- .../PackingCategoryAssignees.entity.ts | 2 +- .../PackingItemContributors.entity.ts | 2 +- .../db/entities/PhotoProviderFields.entity.ts | 2 +- server/src/db/entities/PlaceRatings.entity.ts | 2 +- .../ReservationDayPositions.entity.ts | 2 +- .../entities/ReservationTravelers.entity.ts | 2 +- .../entities/RoadtripDayBoundaries.entity.ts | 2 +- .../db/entities/RoadtripPreferences.entity.ts | 2 +- .../entities/SchoolHolidayRegions.entity.ts | 2 +- server/src/db/entities/Settings.entity.ts | 2 +- .../entities/TodoCategoryAssignees.entity.ts | 2 +- .../src/db/entities/TripAlbumLinks.entity.ts | 2 +- .../db/entities/TripDocumentLinks.entity.ts | 2 +- .../db/entities/TripInviteTokens.entity.ts | 2 +- server/src/db/entities/TripMembers.entity.ts | 2 +- server/src/db/entities/TripPhotos.entity.ts | 2 +- .../entities/UserNoticeDismissals.entity.ts | 2 +- .../entities/VacayCompanyHolidays.entity.ts | 2 +- server/src/db/entities/VacayEntries.entity.ts | 2 +- .../db/entities/VacayPlanMembers.entity.ts | 2 +- server/src/db/entities/VacayPlans.entity.ts | 2 +- server/src/db/entities/VacayShares.entity.ts | 2 +- .../src/db/entities/VacayUserColors.entity.ts | 2 +- .../src/db/entities/VacayUserYears.entity.ts | 2 +- server/src/db/entities/VacayYears.entity.ts | 2 +- .../db/entities/VisitedCountries.entity.ts | 2 +- .../src/db/entities/VisitedRegions.entity.ts | 2 +- .../tests/unit/db/generate-entities.test.ts | 40 ++++++++-- .../repositories/Settings.repository.test.ts | 49 ++++++------ 52 files changed, 171 insertions(+), 96 deletions(-) diff --git a/server/scripts/generate-entities.ts b/server/scripts/generate-entities.ts index 2c278cedf..df1dae26c 100644 --- a/server/scripts/generate-entities.ts +++ b/server/scripts/generate-entities.ts @@ -673,11 +673,13 @@ export function RULE8_bindRepositories(metadata: EntityMetadata[]): void { * idx_reservations_external`, …) survives that filter and already renders * correctly with no help from this rule — only the inline/unnamed spelling is * silently dropped. `settings`'s `UNIQUE(user_id, key)` is the ruling's - * trigger case (the ON CONFLICT target `em.upsert` needs), but the same gap - * exists on ~40 other tables in the schema (grep `UNIQUE(` across - * `db/migrations/*.ts`), so this rule is general — every table, not just - * `settings` — and this task's report says which entities' `uniques:` this - * adds where none existed in the hand-written baseline before. + * trigger case (metadata FIDELITY for the constraint `em.upsert`'s conflict + * target is documented against — see below for what that entry does and + * does not buy), but the same gap exists on ~40 other tables in the schema + * (grep `UNIQUE(` across `db/migrations/*.ts`), so this rule is general — + * every table, not just `settings` — and this task's report says which + * entities' `uniques:` this adds where none existed in the hand-written + * baseline before. * * `implicitUniques` is precomputed once per generator run and handed in as * plain data (table name -> one string[] of column names per implicit unique @@ -685,16 +687,42 @@ export function RULE8_bindRepositories(metadata: EntityMetadata[]): void { * function like every other rule in this file — it never opens a connection * itself. * - * Column names double as property names here without a naming-strategy - * lookup: `SnakeProps.columnNameToProperty` is the identity (D1), and every - * FK's persisted scalar twin is already named exactly like its column - * (`trip_id`, `user_id`) — see the composite `uniques:` blocks already - * hand-written on `Reservations`/`DocumentSyncItems`/`TrekPhotos`/`FileLinks`, - * which reference the twin, never the relation. A column an implicit index - * names that has no matching property (naming-strategy mismatch, or a typo in - * the migration) throws, naming the table/columns/entity, rather than - * silently emitting a `uniques:` entry the ORM would reject at discovery - * time. + * A column that IS a single-column FK's join column (the `persist(false)` + * twin's column, e.g. `settings.user_id`) is emitted as the RELATION + * property name (`user`), never the twin (`user_id`) — looked up from + * `meta.relations` (already renamed by RULE5 by the time this rule runs, so + * this sees the final, post-rename property name) by matching an owning + * to-one relation's single `fieldNames[0]` against the column. Every other + * column doubles as its own property name with no naming-strategy lookup + * (`SnakeProps.columnNameToProperty` is the identity, D1). + * + * This is a deliberate DIVERGENCE from the pre-existing hand-written/ + * generated composite `uniques:` blocks elsewhere in the schema + * (`Reservations`/`DocumentSyncItems`/`TrekPhotos`/`FileLinks` all reference + * the twin — `['file_id', 'budget_item_id']`, never `['file', 'budgetItem']` + * — because those are NAMED `CREATE UNIQUE INDEX`es MikroORM's own + * introspection already renders, untouched by this rule, and it always uses + * column names there). The reason for the divergence: unlike the twin + * spelling, the relation-property spelling is actually consumable by + * `em.upsert`'s own conflict-target inference — `getWhereCondition` in + * `@mikro-orm/core/utils/upsert-utils.js` matches a `uniques` entry's + * `properties` against the upsert payload's own keys, which are PROPERTY + * names (`data.user`, never `data.user_id`, since nobody passes a + * `persist(false)` twin into `em.upsert`'s data). Proven directly: + * `Settings.repository.test.ts`'s SETTINGSREPO-014 calls `em.upsert` on + * `Settings` with no `onConflictFields` at all and still gets + * `on conflict (\`user_id\`, \`key\`)`. + * + * That said, this rule's job is METADATA FIDELITY — the generated entity + * matching what the migrated schema actually constrains — not a functional + * dependency any caller has on it: `SettingsRepository.upsertForUser` keeps + * its `onConflictFields: ['user', 'key']` explicit regardless (clearer at + * the call site than relying on inference from metadata), and every other + * `uniques:` entry this rule adds has no `em.upsert` caller at all yet. A + * column an implicit index names that has no matching property AND no + * matching relation twin (naming-strategy mismatch, or a typo in the + * migration) throws, naming the table/columns/entity, rather than silently + * emitting a `uniques:` entry the ORM would reject at discovery time. * * A column set that is exactly the table's own primary key is skipped: a * non-`INTEGER` (or composite) PRIMARY KEY gets the identical kind of @@ -713,17 +741,29 @@ export function RULE9_addImplicitUniqueConstraints( const indexes = implicitUniques.get(meta.tableName); if (!indexes) continue; const primaryKeys = new Set(meta.primaryKeys); + // A single-column owning to-one relation's join column, keyed by that + // column so a FK twin (`user_id`) resolves to the relation's own + // (post-RULE5-rename) property name (`user`) — see the doc comment above + // for why this, rather than the twin, is what gets emitted. + const relationNameByColumn = new Map(); + for (const prop of meta.relations) { + if (!isOwningToOne(prop) || prop.fieldNames.length !== 1) continue; + relationNameByColumn.set(prop.fieldNames[0], prop.name); + } for (const columns of indexes) { if (columns.length === primaryKeys.size && columns.every((column) => primaryKeys.has(column))) continue; - for (const column of columns) { + const properties = columns.map((column) => { + const relationName = relationNameByColumn.get(column); + if (relationName) return relationName; if (!meta.properties[column]) { throw new Error( `generate-entities: implicit unique index on ${meta.tableName}(${columns.join(', ')}) references ` + `column "${column}", which has no matching property on ${meta.className} (naming-strategy mismatch?).`, ); } - } - meta.uniques.push({ properties: [...columns] }); + return column; + }); + meta.uniques.push({ properties }); } } } diff --git a/server/src/db/entities/AssignmentParticipants.entity.ts b/server/src/db/entities/AssignmentParticipants.entity.ts index 542cb1f8d..96c4a8b22 100644 --- a/server/src/db/entities/AssignmentParticipants.entity.ts +++ b/server/src/db/entities/AssignmentParticipants.entity.ts @@ -14,7 +14,7 @@ export class AssignmentParticipants { export const AssignmentParticipantsSchema = defineEntity({ class: AssignmentParticipants, repository: () => AssignmentParticipantsRepository, - uniques: [{ properties: ['assignment_id', 'user_id'] }], + uniques: [{ properties: ['assignment', 'user'] }], properties: { id: p.integer().primary(), assignment: () => p.manyToOne(DayAssignments).ref().hidden().index('idx_assignment_participants_assignment'), diff --git a/server/src/db/entities/BudgetCategoryOrder.entity.ts b/server/src/db/entities/BudgetCategoryOrder.entity.ts index 8bbc63a71..a433c5a72 100644 --- a/server/src/db/entities/BudgetCategoryOrder.entity.ts +++ b/server/src/db/entities/BudgetCategoryOrder.entity.ts @@ -13,7 +13,7 @@ export class BudgetCategoryOrder { export const BudgetCategoryOrderSchema = defineEntity({ class: BudgetCategoryOrder, repository: () => BudgetCategoryOrderRepository, - uniques: [{ properties: ['trip_id', 'category'] }], + uniques: [{ properties: ['trip', 'category'] }], properties: { trip: () => p.manyToOne(Trips).primary().ref().deleteRule('cascade').hidden(), trip_id: p.integer().persist(false), diff --git a/server/src/db/entities/BudgetItemMembers.entity.ts b/server/src/db/entities/BudgetItemMembers.entity.ts index 031f05b0c..2148f13bd 100644 --- a/server/src/db/entities/BudgetItemMembers.entity.ts +++ b/server/src/db/entities/BudgetItemMembers.entity.ts @@ -16,7 +16,7 @@ export class BudgetItemMembers { export const BudgetItemMembersSchema = defineEntity({ class: BudgetItemMembers, repository: () => BudgetItemMembersRepository, - uniques: [{ properties: ['budget_item_id', 'user_id'] }], + uniques: [{ properties: ['budgetItem', 'user'] }], properties: { id: p.integer().primary(), budgetItem: () => p.manyToOne(BudgetItems).ref().deleteRule('cascade').hidden().index('idx_budget_item_members_item'), diff --git a/server/src/db/entities/BudgetItemPayers.entity.ts b/server/src/db/entities/BudgetItemPayers.entity.ts index 55398578e..ad6a1f49c 100644 --- a/server/src/db/entities/BudgetItemPayers.entity.ts +++ b/server/src/db/entities/BudgetItemPayers.entity.ts @@ -15,7 +15,7 @@ export class BudgetItemPayers { export const BudgetItemPayersSchema = defineEntity({ class: BudgetItemPayers, repository: () => BudgetItemPayersRepository, - uniques: [{ properties: ['budget_item_id', 'user_id'] }], + uniques: [{ properties: ['budgetItem', 'user'] }], properties: { id: p.integer().primary(), budgetItem: () => p.manyToOne(BudgetItems).ref().deleteRule('cascade').hidden().index('idx_budget_item_payers_item'), diff --git a/server/src/db/entities/CollabMessageReactions.entity.ts b/server/src/db/entities/CollabMessageReactions.entity.ts index 0c2ebe301..2c7150b70 100644 --- a/server/src/db/entities/CollabMessageReactions.entity.ts +++ b/server/src/db/entities/CollabMessageReactions.entity.ts @@ -17,7 +17,7 @@ export class CollabMessageReactions { export const CollabMessageReactionsSchema = defineEntity({ class: CollabMessageReactions, repository: () => CollabMessageReactionsRepository, - uniques: [{ properties: ['message_id', 'user_id', 'emoji'] }], + uniques: [{ properties: ['message', 'user', 'emoji'] }], properties: { id: p.integer().primary(), message: () => p.manyToOne(CollabMessages).ref().deleteRule('cascade').hidden().index('idx_collab_reactions_msg'), diff --git a/server/src/db/entities/CollabPollVotes.entity.ts b/server/src/db/entities/CollabPollVotes.entity.ts index b81e69337..6fd3ae6ce 100644 --- a/server/src/db/entities/CollabPollVotes.entity.ts +++ b/server/src/db/entities/CollabPollVotes.entity.ts @@ -17,7 +17,7 @@ export class CollabPollVotes { export const CollabPollVotesSchema = defineEntity({ class: CollabPollVotes, repository: () => CollabPollVotesRepository, - uniques: [{ properties: ['poll_id', 'user_id', 'option_index'] }], + uniques: [{ properties: ['poll', 'user', 'option_index'] }], properties: { id: p.integer().primary(), poll: () => p.manyToOne(CollabPolls).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/CollectionMembers.entity.ts b/server/src/db/entities/CollectionMembers.entity.ts index 136344124..5228b46cd 100644 --- a/server/src/db/entities/CollectionMembers.entity.ts +++ b/server/src/db/entities/CollectionMembers.entity.ts @@ -18,7 +18,7 @@ export class CollectionMembers { export const CollectionMembersSchema = defineEntity({ class: CollectionMembers, repository: () => CollectionMembersRepository, - uniques: [{ properties: ['collection_id', 'user_id'] }], + uniques: [{ properties: ['collection', 'user'] }], properties: { id: p.integer().primary(), collection: () => p.manyToOne(Collections).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/CollectionPlaceRatings.entity.ts b/server/src/db/entities/CollectionPlaceRatings.entity.ts index e263fe949..fa187d8ab 100644 --- a/server/src/db/entities/CollectionPlaceRatings.entity.ts +++ b/server/src/db/entities/CollectionPlaceRatings.entity.ts @@ -17,7 +17,7 @@ export class CollectionPlaceRatings { export const CollectionPlaceRatingsSchema = defineEntity({ class: CollectionPlaceRatings, repository: () => CollectionPlaceRatingsRepository, - uniques: [{ properties: ['collection_place_id', 'user_id'] }], + uniques: [{ properties: ['collectionPlace', 'user'] }], properties: { id: p.integer().primary(), collectionPlace: () => p.manyToOne(CollectionPlaces).ref().deleteRule('cascade').hidden().index('idx_collection_place_ratings_place'), diff --git a/server/src/db/entities/DawarichVisitSuggestions.entity.ts b/server/src/db/entities/DawarichVisitSuggestions.entity.ts index 69113ba94..c61fbf016 100644 --- a/server/src/db/entities/DawarichVisitSuggestions.entity.ts +++ b/server/src/db/entities/DawarichVisitSuggestions.entity.ts @@ -48,7 +48,7 @@ export const DawarichVisitSuggestionsSchema = defineEntity({ properties: ['user_id', 'state'], }, ], - uniques: [{ properties: ['user_id', 'source_visit_id'] }], + uniques: [{ properties: ['user', 'source_visit_id'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/Days.entity.ts b/server/src/db/entities/Days.entity.ts index 31c8fda36..29a0594b7 100644 --- a/server/src/db/entities/Days.entity.ts +++ b/server/src/db/entities/Days.entity.ts @@ -34,7 +34,7 @@ export class Days { export const DaysSchema = defineEntity({ class: Days, repository: () => DaysRepository, - uniques: [{ properties: ['trip_id', 'day_number'] }], + uniques: [{ properties: ['trip', 'day_number'] }], properties: { id: p.integer().primary(), trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_days_trip_id'), diff --git a/server/src/db/entities/DocumentConnections.entity.ts b/server/src/db/entities/DocumentConnections.entity.ts index a44ccb98c..1f0fcb990 100644 --- a/server/src/db/entities/DocumentConnections.entity.ts +++ b/server/src/db/entities/DocumentConnections.entity.ts @@ -30,7 +30,7 @@ export class DocumentConnections { export const DocumentConnectionsSchema = defineEntity({ class: DocumentConnections, repository: () => DocumentConnectionsRepository, - uniques: [{ properties: ['trip_id', 'provider_id'] }], + uniques: [{ properties: ['trip', 'provider'] }], properties: { id: p.integer().primary(), trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_document_connections_trip'), diff --git a/server/src/db/entities/DocumentProviderFields.entity.ts b/server/src/db/entities/DocumentProviderFields.entity.ts index 4ad691244..677e91809 100644 --- a/server/src/db/entities/DocumentProviderFields.entity.ts +++ b/server/src/db/entities/DocumentProviderFields.entity.ts @@ -19,7 +19,7 @@ export class DocumentProviderFields { export const DocumentProviderFieldsSchema = defineEntity({ class: DocumentProviderFields, repository: () => DocumentProviderFieldsRepository, - uniques: [{ properties: ['provider_id', 'field_key'] }], + uniques: [{ properties: ['provider', 'field_key'] }], properties: { id: p.integer().primary(), provider: () => p.manyToOne(DocumentProviders).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/FileLinks.entity.ts b/server/src/db/entities/FileLinks.entity.ts index 1bc4b488c..adf088067 100644 --- a/server/src/db/entities/FileLinks.entity.ts +++ b/server/src/db/entities/FileLinks.entity.ts @@ -30,9 +30,9 @@ export const FileLinksSchema = defineEntity({ name: 'idx_file_links_file_budget', properties: ['file_id', 'budget_item_id'], }, - { properties: ['file_id', 'place_id'] }, - { properties: ['file_id', 'assignment_id'] }, - { properties: ['file_id', 'reservation_id'] }, + { properties: ['file', 'place'] }, + { properties: ['file', 'assignment'] }, + { properties: ['file', 'reservation'] }, ], properties: { id: p.integer().primary(), diff --git a/server/src/db/entities/HiddenCountries.entity.ts b/server/src/db/entities/HiddenCountries.entity.ts index 67aabc26b..84eee9452 100644 --- a/server/src/db/entities/HiddenCountries.entity.ts +++ b/server/src/db/entities/HiddenCountries.entity.ts @@ -14,7 +14,7 @@ export class HiddenCountries { export const HiddenCountriesSchema = defineEntity({ class: HiddenCountries, repository: () => HiddenCountriesRepository, - uniques: [{ properties: ['user_id', 'country_code'] }], + uniques: [{ properties: ['user', 'country_code'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_hidden_countries_user'), diff --git a/server/src/db/entities/HiddenRegions.entity.ts b/server/src/db/entities/HiddenRegions.entity.ts index 0a44677ad..89eeb01b9 100644 --- a/server/src/db/entities/HiddenRegions.entity.ts +++ b/server/src/db/entities/HiddenRegions.entity.ts @@ -15,7 +15,7 @@ export class HiddenRegions { export const HiddenRegionsSchema = defineEntity({ class: HiddenRegions, repository: () => HiddenRegionsRepository, - uniques: [{ properties: ['user_id', 'region_code'] }], + uniques: [{ properties: ['user', 'region_code'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_hidden_regions_user'), diff --git a/server/src/db/entities/IdempotencyKeys.entity.ts b/server/src/db/entities/IdempotencyKeys.entity.ts index 5c95d7bf6..8574e603f 100644 --- a/server/src/db/entities/IdempotencyKeys.entity.ts +++ b/server/src/db/entities/IdempotencyKeys.entity.ts @@ -17,7 +17,7 @@ export class IdempotencyKeys { export const IdempotencyKeysSchema = defineEntity({ class: IdempotencyKeys, repository: () => IdempotencyKeysRepository, - uniques: [{ properties: ['key', 'user_id', 'method', 'path'] }], + uniques: [{ properties: ['key', 'user', 'method', 'path'] }], properties: { key: p.text().primary(), user: () => p.manyToOne(Users).primary().ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/JourneyContributors.entity.ts b/server/src/db/entities/JourneyContributors.entity.ts index cd0f002af..b09e499db 100644 --- a/server/src/db/entities/JourneyContributors.entity.ts +++ b/server/src/db/entities/JourneyContributors.entity.ts @@ -17,7 +17,7 @@ export class JourneyContributors { export const JourneyContributorsSchema = defineEntity({ class: JourneyContributors, repository: () => JourneyContributorsRepository, - uniques: [{ properties: ['journey_id', 'user_id'] }], + uniques: [{ properties: ['journey', 'user'] }], properties: { journey: () => p.manyToOne(Journeys).primary().ref().hidden(), journey_id: p.integer().persist(false), diff --git a/server/src/db/entities/JourneyEntryPhotos.entity.ts b/server/src/db/entities/JourneyEntryPhotos.entity.ts index c87089fec..04fff7878 100644 --- a/server/src/db/entities/JourneyEntryPhotos.entity.ts +++ b/server/src/db/entities/JourneyEntryPhotos.entity.ts @@ -16,7 +16,7 @@ export class JourneyEntryPhotos { export const JourneyEntryPhotosSchema = defineEntity({ class: JourneyEntryPhotos, repository: () => JourneyEntryPhotosRepository, - uniques: [{ properties: ['entry_id', 'journey_photo_id'] }], + uniques: [{ properties: ['entry', 'journeyPhoto'] }], properties: { entry: () => p.manyToOne(JourneyEntries).primary().ref().hidden().index('idx_journey_entry_photos_entry'), entry_id: p.integer().persist(false).index('idx_journey_entry_photos_entry'), diff --git a/server/src/db/entities/JourneyPhotos.entity.ts b/server/src/db/entities/JourneyPhotos.entity.ts index 3f5950f09..4e45087a6 100644 --- a/server/src/db/entities/JourneyPhotos.entity.ts +++ b/server/src/db/entities/JourneyPhotos.entity.ts @@ -23,7 +23,7 @@ export class JourneyPhotos { export const JourneyPhotosSchema = defineEntity({ class: JourneyPhotos, repository: () => JourneyPhotosRepository, - uniques: [{ properties: ['journey_id', 'photo_id'] }], + uniques: [{ properties: ['journey', 'photo'] }], properties: { id: p.integer().primary(), journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden().index('idx_journey_photos_journey'), diff --git a/server/src/db/entities/JourneyTrips.entity.ts b/server/src/db/entities/JourneyTrips.entity.ts index ea77543d3..7a7169124 100644 --- a/server/src/db/entities/JourneyTrips.entity.ts +++ b/server/src/db/entities/JourneyTrips.entity.ts @@ -15,7 +15,7 @@ export class JourneyTrips { export const JourneyTripsSchema = defineEntity({ class: JourneyTrips, repository: () => JourneyTripsRepository, - uniques: [{ properties: ['journey_id', 'trip_id'] }], + uniques: [{ properties: ['journey', 'trip'] }], properties: { journey: () => p.manyToOne(Journeys).primary().ref().hidden().index('idx_journey_trips_journey'), journey_id: p.integer().persist(false).index('idx_journey_trips_journey'), diff --git a/server/src/db/entities/NotificationChannelPreferences.entity.ts b/server/src/db/entities/NotificationChannelPreferences.entity.ts index 3c4a9729b..7b2bdf76f 100644 --- a/server/src/db/entities/NotificationChannelPreferences.entity.ts +++ b/server/src/db/entities/NotificationChannelPreferences.entity.ts @@ -14,7 +14,7 @@ export class NotificationChannelPreferences { export const NotificationChannelPreferencesSchema = defineEntity({ class: NotificationChannelPreferences, repository: () => NotificationChannelPreferencesRepository, - uniques: [{ properties: ['user_id', 'event_type', 'channel'] }], + uniques: [{ properties: ['user', 'event_type', 'channel'] }], properties: { user: () => p.manyToOne(Users).primary().ref().deleteRule('cascade').hidden().index('idx_ncp_user'), user_id: p.integer().persist(false).index('idx_ncp_user'), diff --git a/server/src/db/entities/OauthConsents.entity.ts b/server/src/db/entities/OauthConsents.entity.ts index c2f2eee12..f29e06ea7 100644 --- a/server/src/db/entities/OauthConsents.entity.ts +++ b/server/src/db/entities/OauthConsents.entity.ts @@ -17,7 +17,7 @@ export class OauthConsents { export const OauthConsentsSchema = defineEntity({ class: OauthConsents, repository: () => OauthConsentsRepository, - uniques: [{ properties: ['client_id', 'user_id'] }], + uniques: [{ properties: ['client', 'user'] }], properties: { id: p.integer().primary(), client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/PackingCategoryAssignees.entity.ts b/server/src/db/entities/PackingCategoryAssignees.entity.ts index b595c3a84..855fa1f08 100644 --- a/server/src/db/entities/PackingCategoryAssignees.entity.ts +++ b/server/src/db/entities/PackingCategoryAssignees.entity.ts @@ -15,7 +15,7 @@ export class PackingCategoryAssignees { export const PackingCategoryAssigneesSchema = defineEntity({ class: PackingCategoryAssignees, repository: () => PackingCategoryAssigneesRepository, - uniques: [{ properties: ['trip_id', 'category_name', 'user_id'] }], + uniques: [{ properties: ['trip', 'category_name', 'user'] }], properties: { id: p.integer().primary(), trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/PackingItemContributors.entity.ts b/server/src/db/entities/PackingItemContributors.entity.ts index aa258c455..8ea945655 100644 --- a/server/src/db/entities/PackingItemContributors.entity.ts +++ b/server/src/db/entities/PackingItemContributors.entity.ts @@ -17,7 +17,7 @@ export class PackingItemContributors { export const PackingItemContributorsSchema = defineEntity({ class: PackingItemContributors, repository: () => PackingItemContributorsRepository, - uniques: [{ properties: ['item_id', 'user_id'] }], + uniques: [{ properties: ['item', 'user'] }], properties: { item: () => p.manyToOne(PackingItems).primary().ref().hidden(), item_id: p.integer().persist(false), diff --git a/server/src/db/entities/PhotoProviderFields.entity.ts b/server/src/db/entities/PhotoProviderFields.entity.ts index 9d4dbc3cd..824a46285 100644 --- a/server/src/db/entities/PhotoProviderFields.entity.ts +++ b/server/src/db/entities/PhotoProviderFields.entity.ts @@ -21,7 +21,7 @@ export class PhotoProviderFields { export const PhotoProviderFieldsSchema = defineEntity({ class: PhotoProviderFields, repository: () => PhotoProviderFieldsRepository, - uniques: [{ properties: ['provider_id', 'field_key'] }], + uniques: [{ properties: ['provider', 'field_key'] }], properties: { id: p.integer().primary(), provider: () => p.manyToOne(PhotoProviders).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/PlaceRatings.entity.ts b/server/src/db/entities/PlaceRatings.entity.ts index 0d3567179..055eea99c 100644 --- a/server/src/db/entities/PlaceRatings.entity.ts +++ b/server/src/db/entities/PlaceRatings.entity.ts @@ -17,7 +17,7 @@ export class PlaceRatings { export const PlaceRatingsSchema = defineEntity({ class: PlaceRatings, repository: () => PlaceRatingsRepository, - uniques: [{ properties: ['place_id', 'user_id'] }], + uniques: [{ properties: ['place', 'user'] }], properties: { id: p.integer().primary(), place: () => p.manyToOne(Places).ref().deleteRule('cascade').hidden().index('idx_place_ratings_place'), diff --git a/server/src/db/entities/ReservationDayPositions.entity.ts b/server/src/db/entities/ReservationDayPositions.entity.ts index f95bfd935..a096cdd8e 100644 --- a/server/src/db/entities/ReservationDayPositions.entity.ts +++ b/server/src/db/entities/ReservationDayPositions.entity.ts @@ -15,7 +15,7 @@ export class ReservationDayPositions { export const ReservationDayPositionsSchema = defineEntity({ class: ReservationDayPositions, repository: () => ReservationDayPositionsRepository, - uniques: [{ properties: ['reservation_id', 'day_id'] }], + uniques: [{ properties: ['reservation', 'day'] }], properties: { reservation: () => p.manyToOne(Reservations).primary().ref().hidden(), reservation_id: p.integer().persist(false), diff --git a/server/src/db/entities/ReservationTravelers.entity.ts b/server/src/db/entities/ReservationTravelers.entity.ts index baa46edfd..bfd1ac3ca 100644 --- a/server/src/db/entities/ReservationTravelers.entity.ts +++ b/server/src/db/entities/ReservationTravelers.entity.ts @@ -14,7 +14,7 @@ export class ReservationTravelers { export const ReservationTravelersSchema = defineEntity({ class: ReservationTravelers, repository: () => ReservationTravelersRepository, - uniques: [{ properties: ['reservation_id', 'user_id'] }], + uniques: [{ properties: ['reservation', 'user'] }], properties: { id: p.integer().primary(), reservation: () => p.manyToOne(Reservations).ref().hidden().index('idx_reservation_travelers_res'), diff --git a/server/src/db/entities/RoadtripDayBoundaries.entity.ts b/server/src/db/entities/RoadtripDayBoundaries.entity.ts index 9ff933b4a..7bbd99ed7 100644 --- a/server/src/db/entities/RoadtripDayBoundaries.entity.ts +++ b/server/src/db/entities/RoadtripDayBoundaries.entity.ts @@ -18,7 +18,7 @@ export class RoadtripDayBoundaries { export const RoadtripDayBoundariesSchema = defineEntity({ class: RoadtripDayBoundaries, repository: () => RoadtripDayBoundariesRepository, - uniques: [{ properties: ['trip_id', 'day_number'] }], + uniques: [{ properties: ['trip', 'day_number'] }], checks: [ { name: 'roadtrip_day_boundaries_fraction_check', diff --git a/server/src/db/entities/RoadtripPreferences.entity.ts b/server/src/db/entities/RoadtripPreferences.entity.ts index 374b092fb..3073e5566 100644 --- a/server/src/db/entities/RoadtripPreferences.entity.ts +++ b/server/src/db/entities/RoadtripPreferences.entity.ts @@ -13,7 +13,7 @@ export class RoadtripPreferences { export const RoadtripPreferencesSchema = defineEntity({ class: RoadtripPreferences, repository: () => RoadtripPreferencesRepository, - uniques: [{ properties: ['trip_id', 'key'] }], + uniques: [{ properties: ['trip', 'key'] }], properties: { trip: () => p.manyToOne(Trips).primary().ref().deleteRule('cascade').hidden(), trip_id: p.integer().persist(false), diff --git a/server/src/db/entities/SchoolHolidayRegions.entity.ts b/server/src/db/entities/SchoolHolidayRegions.entity.ts index 55d0995d8..c8328e5ad 100644 --- a/server/src/db/entities/SchoolHolidayRegions.entity.ts +++ b/server/src/db/entities/SchoolHolidayRegions.entity.ts @@ -15,7 +15,7 @@ export class SchoolHolidayRegions { export const SchoolHolidayRegionsSchema = defineEntity({ class: SchoolHolidayRegions, repository: () => SchoolHolidayRegionsRepository, - uniques: [{ properties: ['country', 'name'] }], + uniques: [{ properties: ['countryRef', 'name'] }], properties: { id: p.integer().primary(), country: p.text().persist(false), diff --git a/server/src/db/entities/Settings.entity.ts b/server/src/db/entities/Settings.entity.ts index 0654633b8..c70994d34 100644 --- a/server/src/db/entities/Settings.entity.ts +++ b/server/src/db/entities/Settings.entity.ts @@ -13,7 +13,7 @@ export class Settings { export const SettingsSchema = defineEntity({ class: Settings, repository: () => SettingsRepository, - uniques: [{ properties: ['user_id', 'key'] }], + uniques: [{ properties: ['user', 'key'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/TodoCategoryAssignees.entity.ts b/server/src/db/entities/TodoCategoryAssignees.entity.ts index 53e7f5150..96cce6a26 100644 --- a/server/src/db/entities/TodoCategoryAssignees.entity.ts +++ b/server/src/db/entities/TodoCategoryAssignees.entity.ts @@ -15,7 +15,7 @@ export class TodoCategoryAssignees { export const TodoCategoryAssigneesSchema = defineEntity({ class: TodoCategoryAssignees, repository: () => TodoCategoryAssigneesRepository, - uniques: [{ properties: ['trip_id', 'category_name', 'user_id'] }], + uniques: [{ properties: ['trip', 'category_name', 'user'] }], properties: { id: p.integer().primary(), trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/TripAlbumLinks.entity.ts b/server/src/db/entities/TripAlbumLinks.entity.ts index 66e02f67b..fe139828f 100644 --- a/server/src/db/entities/TripAlbumLinks.entity.ts +++ b/server/src/db/entities/TripAlbumLinks.entity.ts @@ -24,7 +24,7 @@ export class TripAlbumLinks { export const TripAlbumLinksSchema = defineEntity({ class: TripAlbumLinks, repository: () => TripAlbumLinksRepository, - uniques: [{ properties: ['trip_id', 'user_id', 'provider', 'album_id'] }], + uniques: [{ properties: ['trip', 'user', 'provider', 'album_id'] }], properties: { id: p.integer().primary(), trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_album_links_trip'), diff --git a/server/src/db/entities/TripDocumentLinks.entity.ts b/server/src/db/entities/TripDocumentLinks.entity.ts index 7c5e7bcdb..a5475d539 100644 --- a/server/src/db/entities/TripDocumentLinks.entity.ts +++ b/server/src/db/entities/TripDocumentLinks.entity.ts @@ -52,7 +52,7 @@ export const TripDocumentLinksSchema = defineEntity({ where: 'webhook_token IS NOT NULL', properties: ['webhook_token'], }, - { properties: ['trip_id', 'connection_id', 'remote_scope_key'] }, + { properties: ['trip', 'connection', 'remote_scope_key'] }, ], properties: { id: p.integer().primary(), diff --git a/server/src/db/entities/TripInviteTokens.entity.ts b/server/src/db/entities/TripInviteTokens.entity.ts index c814a4e31..c5e070d66 100644 --- a/server/src/db/entities/TripInviteTokens.entity.ts +++ b/server/src/db/entities/TripInviteTokens.entity.ts @@ -18,7 +18,7 @@ export class TripInviteTokens { export const TripInviteTokensSchema = defineEntity({ class: TripInviteTokens, repository: () => TripInviteTokensRepository, - uniques: [{ properties: ['token'] }, { properties: ['trip_id'] }], + uniques: [{ properties: ['token'] }, { properties: ['trip'] }], properties: { id: p.integer().primary(), trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/TripMembers.entity.ts b/server/src/db/entities/TripMembers.entity.ts index a6570a587..1b10abef3 100644 --- a/server/src/db/entities/TripMembers.entity.ts +++ b/server/src/db/entities/TripMembers.entity.ts @@ -18,7 +18,7 @@ export class TripMembers { export const TripMembersSchema = defineEntity({ class: TripMembers, repository: () => TripMembersRepository, - uniques: [{ properties: ['trip_id', 'user_id'] }], + uniques: [{ properties: ['trip', 'user'] }], properties: { id: p.integer().primary(), trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_members_trip_id'), diff --git a/server/src/db/entities/TripPhotos.entity.ts b/server/src/db/entities/TripPhotos.entity.ts index 7089bce7d..930fed041 100644 --- a/server/src/db/entities/TripPhotos.entity.ts +++ b/server/src/db/entities/TripPhotos.entity.ts @@ -23,7 +23,7 @@ export class TripPhotos { export const TripPhotosSchema = defineEntity({ class: TripPhotos, repository: () => TripPhotosRepository, - uniques: [{ properties: ['trip_id', 'user_id', 'photo_id'] }], + uniques: [{ properties: ['trip', 'user', 'photo'] }], properties: { id: p.integer().primary(), trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_photos_trip'), diff --git a/server/src/db/entities/UserNoticeDismissals.entity.ts b/server/src/db/entities/UserNoticeDismissals.entity.ts index bd8646d3f..39aac796f 100644 --- a/server/src/db/entities/UserNoticeDismissals.entity.ts +++ b/server/src/db/entities/UserNoticeDismissals.entity.ts @@ -14,7 +14,7 @@ export class UserNoticeDismissals { export const UserNoticeDismissalsSchema = defineEntity({ class: UserNoticeDismissals, repository: () => UserNoticeDismissalsRepository, - uniques: [{ properties: ['user_id', 'notice_id'] }], + uniques: [{ properties: ['user', 'notice_id'] }], properties: { user: () => p.manyToOne(Users).primary().ref().deleteRule('cascade').hidden(), user_id: p.integer().persist(false), diff --git a/server/src/db/entities/VacayCompanyHolidays.entity.ts b/server/src/db/entities/VacayCompanyHolidays.entity.ts index 733aa0135..5737d2e20 100644 --- a/server/src/db/entities/VacayCompanyHolidays.entity.ts +++ b/server/src/db/entities/VacayCompanyHolidays.entity.ts @@ -13,7 +13,7 @@ export class VacayCompanyHolidays { export const VacayCompanyHolidaysSchema = defineEntity({ class: VacayCompanyHolidays, repository: () => VacayCompanyHolidaysRepository, - uniques: [{ properties: ['plan_id', 'date'] }], + uniques: [{ properties: ['plan', 'date'] }], properties: { id: p.integer().primary(), plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/VacayEntries.entity.ts b/server/src/db/entities/VacayEntries.entity.ts index 051a91528..6dbe9ba41 100644 --- a/server/src/db/entities/VacayEntries.entity.ts +++ b/server/src/db/entities/VacayEntries.entity.ts @@ -18,7 +18,7 @@ export class VacayEntries { export const VacayEntriesSchema = defineEntity({ class: VacayEntries, repository: () => VacayEntriesRepository, - uniques: [{ properties: ['user_id', 'plan_id', 'date'] }], + uniques: [{ properties: ['user', 'plan', 'date'] }], properties: { id: p.integer().primary(), plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/VacayPlanMembers.entity.ts b/server/src/db/entities/VacayPlanMembers.entity.ts index efae954a3..cc530a85a 100644 --- a/server/src/db/entities/VacayPlanMembers.entity.ts +++ b/server/src/db/entities/VacayPlanMembers.entity.ts @@ -17,7 +17,7 @@ export class VacayPlanMembers { export const VacayPlanMembersSchema = defineEntity({ class: VacayPlanMembers, repository: () => VacayPlanMembersRepository, - uniques: [{ properties: ['plan_id', 'user_id'] }], + uniques: [{ properties: ['plan', 'user'] }], properties: { id: p.integer().primary(), plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/VacayPlans.entity.ts b/server/src/db/entities/VacayPlans.entity.ts index 0310762c1..c0c7fd8a8 100644 --- a/server/src/db/entities/VacayPlans.entity.ts +++ b/server/src/db/entities/VacayPlans.entity.ts @@ -35,7 +35,7 @@ export class VacayPlans { export const VacayPlansSchema = defineEntity({ class: VacayPlans, repository: () => VacayPlansRepository, - uniques: [{ properties: ['owner_id'] }], + uniques: [{ properties: ['owner'] }], properties: { id: p.integer().primary(), owner: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/VacayShares.entity.ts b/server/src/db/entities/VacayShares.entity.ts index b5a63fa01..7fc1c0474 100644 --- a/server/src/db/entities/VacayShares.entity.ts +++ b/server/src/db/entities/VacayShares.entity.ts @@ -16,7 +16,7 @@ export class VacayShares { export const VacaySharesSchema = defineEntity({ class: VacayShares, repository: () => VacaySharesRepository, - uniques: [{ properties: ['owner_id', 'user_id'] }], + uniques: [{ properties: ['owner', 'user'] }], properties: { id: p.integer().primary(), owner: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/VacayUserColors.entity.ts b/server/src/db/entities/VacayUserColors.entity.ts index 47fafe0f7..6cc8da471 100644 --- a/server/src/db/entities/VacayUserColors.entity.ts +++ b/server/src/db/entities/VacayUserColors.entity.ts @@ -15,7 +15,7 @@ export class VacayUserColors { export const VacayUserColorsSchema = defineEntity({ class: VacayUserColors, repository: () => VacayUserColorsRepository, - uniques: [{ properties: ['user_id', 'plan_id'] }], + uniques: [{ properties: ['user', 'plan'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/VacayUserYears.entity.ts b/server/src/db/entities/VacayUserYears.entity.ts index 4ed504bda..f9fb7e05d 100644 --- a/server/src/db/entities/VacayUserYears.entity.ts +++ b/server/src/db/entities/VacayUserYears.entity.ts @@ -17,7 +17,7 @@ export class VacayUserYears { export const VacayUserYearsSchema = defineEntity({ class: VacayUserYears, repository: () => VacayUserYearsRepository, - uniques: [{ properties: ['user_id', 'plan_id', 'year'] }], + uniques: [{ properties: ['user', 'plan', 'year'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/VacayYears.entity.ts b/server/src/db/entities/VacayYears.entity.ts index 62489cf36..251b03ebe 100644 --- a/server/src/db/entities/VacayYears.entity.ts +++ b/server/src/db/entities/VacayYears.entity.ts @@ -12,7 +12,7 @@ export class VacayYears { export const VacayYearsSchema = defineEntity({ class: VacayYears, repository: () => VacayYearsRepository, - uniques: [{ properties: ['plan_id', 'year'] }], + uniques: [{ properties: ['plan', 'year'] }], properties: { id: p.integer().primary(), plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/VisitedCountries.entity.ts b/server/src/db/entities/VisitedCountries.entity.ts index fdfe0ed2f..0527fa87a 100644 --- a/server/src/db/entities/VisitedCountries.entity.ts +++ b/server/src/db/entities/VisitedCountries.entity.ts @@ -15,7 +15,7 @@ export class VisitedCountries { export const VisitedCountriesSchema = defineEntity({ class: VisitedCountries, repository: () => VisitedCountriesRepository, - uniques: [{ properties: ['user_id', 'country_code'] }], + uniques: [{ properties: ['user', 'country_code'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/VisitedRegions.entity.ts b/server/src/db/entities/VisitedRegions.entity.ts index b9e41aaf4..39a471885 100644 --- a/server/src/db/entities/VisitedRegions.entity.ts +++ b/server/src/db/entities/VisitedRegions.entity.ts @@ -16,7 +16,7 @@ export class VisitedRegions { export const VisitedRegionsSchema = defineEntity({ class: VisitedRegions, repository: () => VisitedRegionsRepository, - uniques: [{ properties: ['user_id', 'region_code'] }], + uniques: [{ properties: ['user', 'region_code'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), diff --git a/server/tests/unit/db/generate-entities.test.ts b/server/tests/unit/db/generate-entities.test.ts index b148e939c..48d5bab23 100644 --- a/server/tests/unit/db/generate-entities.test.ts +++ b/server/tests/unit/db/generate-entities.test.ts @@ -416,15 +416,45 @@ describe('RULE8_bindRepositories', () => { }); describe('RULE9_addImplicitUniqueConstraints', () => { - it('RULE9-001: a table with a matching implicit-unique entry gets a uniques: block referencing the columns as properties', () => { + it('RULE9-001: a table with a matching implicit-unique entry gets a uniques: block referencing the columns as properties — a plain (non-FK) column keeps its own name', () => { const userId = fixtureProp({ name: 'user_id', primary: false }); const key = fixtureProp({ name: 'key', primary: false }); - const meta = fixtureMeta('Settings', 'settings', [userId, key]); - const implicitUniques = new Map([['settings', [['user_id', 'key']]]]); + const meta = fixtureMeta('X', 'x', [userId, key]); + const implicitUniques = new Map([['x', [['user_id', 'key']]]]); RULE9_addImplicitUniqueConstraints([meta], implicitUniques); expect(meta.uniques).toEqual([{ properties: ['user_id', 'key'] }]); }); + it('RULE9-007: a column that IS a single-column owning relation\'s join column is emitted as the RELATION property name, not the persist(false) twin (Important 1, task-5-review.md — the settings.(user_id, key) real case, post-RULE5-rename property name)', () => { + const user = fixtureProp({ name: 'user', kind: ReferenceKind.MANY_TO_ONE, fieldNames: ['user_id'] }); + const userId = fixtureProp({ name: 'user_id', primary: false, persist: false }); + const key = fixtureProp({ name: 'key', primary: false }); + const meta = fixtureMeta('Settings', 'settings', [user, userId, key]); + const implicitUniques = new Map([['settings', [['user_id', 'key']]]]); + RULE9_addImplicitUniqueConstraints([meta], implicitUniques); + expect(meta.uniques).toEqual([{ properties: ['user', 'key'] }]); + }); + + it('RULE9-008: a multi-column owning relation (fieldNames.length !== 1) is never used for the mapping — the twin column name is kept', () => { + const composite = fixtureProp({ name: 'composite', kind: ReferenceKind.MANY_TO_ONE, fieldNames: ['a_id', 'b_id'] }); + const aId = fixtureProp({ name: 'a_id', primary: false }); + const key = fixtureProp({ name: 'key', primary: false }); + const meta = fixtureMeta('X', 'x', [composite, aId, key]); + const implicitUniques = new Map([['x', [['a_id', 'key']]]]); + RULE9_addImplicitUniqueConstraints([meta], implicitUniques); + expect(meta.uniques).toEqual([{ properties: ['a_id', 'key'] }]); + }); + + it('RULE9-009: an inverse-side relation (mappedBy set, not owning) is never used for the mapping', () => { + const inverse = fixtureProp({ name: 'children', kind: ReferenceKind.ONE_TO_MANY, mappedBy: 'x', fieldNames: [] }); + const xId = fixtureProp({ name: 'x_id', primary: false }); + const key = fixtureProp({ name: 'key', primary: false }); + const meta = fixtureMeta('X', 'x', [inverse, xId, key]); + const implicitUniques = new Map([['x', [['x_id', 'key']]]]); + RULE9_addImplicitUniqueConstraints([meta], implicitUniques); + expect(meta.uniques).toEqual([{ properties: ['x_id', 'key'] }]); + }); + it('RULE9-002: a column set identical to the table\'s own primary key is skipped (Addons.id/AppSettings.key noise, real schema)', () => { const id = fixtureProp({ name: 'id', primary: true }); const meta = fixtureMeta('Addons', 'addons', [id]); @@ -1057,11 +1087,11 @@ describe('generateEntities — validation diff against the five reference entiti }, 30_000); it( - 'UNIQUE-001: Rule 9 end-to-end against the real schema — settings gets the inline UNIQUE(user_id, key) as uniques:, AppSettings/Addons do NOT get a redundant entry for their own (TEXT) primary key', + 'UNIQUE-001: Rule 9 end-to-end against the real schema — settings gets the inline UNIQUE(user_id, key) as uniques:, naming the RELATION property (user), not its persist(false) twin (user_id); AppSettings/Addons do NOT get a redundant entry for their own (TEXT) primary key', async () => { const { files } = await generateEntities(); const settings = files.get('Settings.entity.ts'); - expect(settings).toContain("uniques: [{ properties: ['user_id', 'key'] }],"); + expect(settings).toContain("uniques: [{ properties: ['user', 'key'] }],"); const appSettings = files.get('AppSettings.entity.ts'); expect(appSettings).not.toMatch(/uniques:/); const addons = files.get('Addons.entity.ts'); diff --git a/server/tests/unit/db/repositories/Settings.repository.test.ts b/server/tests/unit/db/repositories/Settings.repository.test.ts index 211ee1786..d926e506f 100644 --- a/server/tests/unit/db/repositories/Settings.repository.test.ts +++ b/server/tests/unit/db/repositories/Settings.repository.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; @@ -50,9 +50,9 @@ describe('settings table — the (user_id, key) unique index the ON CONFLICT tar }, ); - it('UNIQUE-SCHEMA-002: the Settings entity metadata declares the same (user, key) unique constraint (fix(db): generator captures inline UNIQUE constraints)', () => { + it('UNIQUE-SCHEMA-002: the Settings entity metadata declares the same (user, key) unique constraint, naming the RELATION property, not its persist(false) twin (fix(db): generator maps an FK twin column to its relation property name)', () => { const meta = t.orm.getMetadata(Settings); - expect(meta.uniques).toContainEqual({ properties: ['user_id', 'key'] }); + expect(meta.uniques).toContainEqual({ properties: ['user', 'key'] }); }); }); @@ -89,13 +89,13 @@ describe('SettingsRepository', () => { it('SETTINGSREPO-006: upsertForUser inserts a new row exactly as the legacy INSERT would', async () => { await settings.upsertForUser(user.id, 'dark_mode', 'true'); - expect(rawRow(user.id, 'dark_mode')).toMatchObject({ user_id: user.id, key: 'dark_mode', value: 'true' }); + expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' }); }); it('SETTINGSREPO-007: upsertForUser on an existing (user, key) replaces the value only — no duplicate row, matching ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', async () => { insertRaw(user.id, 'dark_mode', 'false'); await settings.upsertForUser(user.id, 'dark_mode', 'true'); - expect(rawRow(user.id, 'dark_mode')).toMatchObject({ user_id: user.id, key: 'dark_mode', value: 'true' }); + expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' }); expect( testDb.prepare('SELECT COUNT(*) as c FROM settings WHERE user_id = ? AND key = ?').get(user.id, 'dark_mode'), ).toEqual({ c: 1 }); @@ -108,25 +108,30 @@ describe('SettingsRepository', () => { expect(rawRow(otherUser.id, 'dark_mode')).toMatchObject({ value: 'false' }); }); - it('SETTINGSREPO-009: deleteForUser with a key deletes only that (user, key) row and reports the legacy DELETE-affected-row count', async () => { - insertRaw(user.id, 'dark_mode', 'true'); - insertRaw(user.id, 'temperature_unit', 'celsius'); - expect(await settings.deleteForUser(user.id, 'dark_mode')).toBe(1); - expect(rawRow(user.id, 'dark_mode')).toBeUndefined(); - expect(rawRow(user.id, 'temperature_unit')).toBeDefined(); - }); + it("SETTINGSREPO-014: em.upsert infers the (user, key) conflict target from the metadata's relation-named uniques entry with NO explicit onConflictFields — the entry the generator's fix(db) commit made consumable (task-5-review.md Important 1). SettingsRepository.upsertForUser still passes onConflictFields explicitly, for clarity, not because it is required.", async () => { + const connection = t.orm.em.getConnection(); + const spy = vi.spyOn(connection, 'execute'); + try { + // Insert branch: no existing row, onConflictFields omitted entirely. + await t.em.upsert(Settings, { user: user.id, key: 'dark_mode', value: 'true' }, { onConflictAction: 'merge' }); + expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' }); - it('SETTINGSREPO-010: deleteForUser without a key deletes every row for that user only', async () => { - insertRaw(user.id, 'dark_mode', 'true'); - insertRaw(user.id, 'temperature_unit', 'celsius'); - insertRaw(otherUser.id, 'dark_mode', 'false'); - expect(await settings.deleteForUser(user.id)).toBe(2); - expect(await settings.getForUser(user.id)).toEqual([]); - expect(rawRow(otherUser.id, 'dark_mode')).toBeDefined(); - }); + // Merge branch: an existing row, same call shape — proves the SAME + // inferred target both creates and merges, not just happens to insert once. + const insertSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql)); + expect(insertSql).toContain('on conflict (`user_id`, `key`)'); + spy.mockClear(); - it('SETTINGSREPO-011: deleteForUser on a missing key is a no-op that reports 0 rows', async () => { - expect(await settings.deleteForUser(user.id, 'does_not_exist')).toBe(0); + await t.em.upsert(Settings, { user: user.id, key: 'dark_mode', value: 'false' }, { onConflictAction: 'merge' }); + expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'false' }); + expect( + testDb.prepare('SELECT COUNT(*) as c FROM settings WHERE user_id = ? AND key = ?').get(user.id, 'dark_mode'), + ).toEqual({ c: 1 }); + const mergeSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql)); + expect(mergeSql).toContain('on conflict (`user_id`, `key`)'); + } finally { + spy.mockRestore(); + } }); // Identity-map regression: the short-circuit (Task 0 review, I1; corrected