mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(db): generator names relation properties in captured UNIQUE constraints
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user