fix(db): generator names relation properties in captured UNIQUE constraints

This commit is contained in:
jubnl
2026-09-22 15:46:46 +02:00
parent 59fa932ec8
commit 570a6ede85
52 changed files with 171 additions and 96 deletions
+58 -18
View File
@@ -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<string, string>();
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 });
}
}
}
@@ -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'),
@@ -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),
@@ -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'),
@@ -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'),
@@ -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'),
@@ -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(),
@@ -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(),
@@ -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'),
@@ -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(),
+1 -1
View File
@@ -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'),
@@ -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'),
@@ -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(),
+3 -3
View File
@@ -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(),
@@ -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'),
@@ -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'),
@@ -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(),
@@ -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),
@@ -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'),
@@ -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'),
@@ -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'),
@@ -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'),
@@ -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(),
@@ -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(),
@@ -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),
@@ -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(),
@@ -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'),
@@ -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),
@@ -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'),
@@ -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',
@@ -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),
@@ -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),
+1 -1
View File
@@ -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(),
@@ -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(),
@@ -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'),
@@ -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(),
@@ -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(),
+1 -1
View File
@@ -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'),
+1 -1
View File
@@ -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'),
@@ -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),
@@ -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(),
@@ -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(),
@@ -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(),
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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(),
@@ -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(),
@@ -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(),
+1 -1
View File
@@ -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(),
@@ -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(),
@@ -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(),
+35 -5
View File
@@ -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');
@@ -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