From 310a3b0ad389d5b48e5b3aa32d495ed6a1ddc752 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 22 Sep 2026 14:13:09 +0200 Subject: [PATCH] fix(db): generator captures inline UNIQUE constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqliteSchemaHelper.getIndexes() drops every unnamed sqlite_autoindex_ entry before entity-generator ever sees it, so a table-level UNIQUE(...) constraint with no separate named CREATE UNIQUE INDEX (settings' UNIQUE(user_id, key) among ~40 others) never reached the generated entity's uniques: metadata. Adds a general RULE9_addImplicitUniqueConstraints (skips a column set identical to the table's own primary key — AppSettings.key/Addons.id noise) fed by a PRAGMA index_list/index_info scan, and regenerates every entity. --- server/scripts/generate-entities.ts | 135 +++++++++++++++++- .../entities/AssignmentParticipants.entity.ts | 1 + .../db/entities/BudgetCategoryOrder.entity.ts | 1 + .../db/entities/BudgetItemMembers.entity.ts | 1 + .../db/entities/BudgetItemPayers.entity.ts | 1 + .../entities/CollabMessageReactions.entity.ts | 1 + .../src/db/entities/CollabPollVotes.entity.ts | 1 + .../db/entities/CollectionMembers.entity.ts | 1 + .../entities/CollectionPlaceRatings.entity.ts | 1 + .../DawarichVisitSuggestions.entity.ts | 1 + server/src/db/entities/Days.entity.ts | 1 + .../db/entities/DocumentConnections.entity.ts | 1 + .../entities/DocumentProviderFields.entity.ts | 1 + server/src/db/entities/FileLinks.entity.ts | 3 + .../src/db/entities/HiddenCountries.entity.ts | 1 + .../src/db/entities/HiddenRegions.entity.ts | 1 + .../src/db/entities/IdempotencyKeys.entity.ts | 1 + server/src/db/entities/InviteTokens.entity.ts | 1 + .../db/entities/JourneyContributors.entity.ts | 1 + .../db/entities/JourneyEntryPhotos.entity.ts | 1 + .../src/db/entities/JourneyPhotos.entity.ts | 1 + .../db/entities/JourneyShareTokens.entity.ts | 1 + server/src/db/entities/JourneyTrips.entity.ts | 1 + .../NotificationChannelPreferences.entity.ts | 1 + server/src/db/entities/OauthClients.entity.ts | 1 + .../src/db/entities/OauthConsents.entity.ts | 1 + server/src/db/entities/OauthTokens.entity.ts | 4 + .../PackingCategoryAssignees.entity.ts | 1 + .../PackingItemContributors.entity.ts | 1 + .../db/entities/PasswordResetTokens.entity.ts | 1 + .../db/entities/PhotoProviderFields.entity.ts | 1 + server/src/db/entities/PlaceRatings.entity.ts | 1 + .../db/entities/PluginEgressHosts.entity.ts | 1 + .../entities/PluginEntityMetadata.entity.ts | 1 + .../entities/PluginScheduledTasks.entity.ts | 1 + .../entities/PluginSettingsFields.entity.ts | 1 + .../entities/PluginUserErasureQueue.entity.ts | 1 + .../ReservationDayPositions.entity.ts | 1 + .../entities/ReservationTravelers.entity.ts | 1 + .../entities/RoadtripDayBoundaries.entity.ts | 1 + .../db/entities/RoadtripPreferences.entity.ts | 1 + .../entities/SchoolHolidayRegions.entity.ts | 1 + server/src/db/entities/Settings.entity.ts | 1 + server/src/db/entities/ShareTokens.entity.ts | 1 + .../entities/TodoCategoryAssignees.entity.ts | 1 + .../src/db/entities/TripAlbumLinks.entity.ts | 1 + .../db/entities/TripDocumentLinks.entity.ts | 1 + .../db/entities/TripInviteTokens.entity.ts | 1 + server/src/db/entities/TripMembers.entity.ts | 1 + server/src/db/entities/TripPhotos.entity.ts | 1 + .../entities/UserNoticeDismissals.entity.ts | 1 + server/src/db/entities/Users.entity.ts | 2 + .../entities/VacayCompanyHolidays.entity.ts | 1 + server/src/db/entities/VacayEntries.entity.ts | 1 + .../db/entities/VacayPlanMembers.entity.ts | 1 + server/src/db/entities/VacayPlans.entity.ts | 1 + server/src/db/entities/VacayShares.entity.ts | 1 + .../src/db/entities/VacayUserColors.entity.ts | 1 + .../src/db/entities/VacayUserYears.entity.ts | 1 + server/src/db/entities/VacayYears.entity.ts | 1 + .../db/entities/VisitedCountries.entity.ts | 1 + .../src/db/entities/VisitedRegions.entity.ts | 1 + .../db/entities/WebauthnChallenges.entity.ts | 1 + .../db/entities/WebauthnCredentials.entity.ts | 1 + .../tests/unit/db/generate-entities.test.ts | 76 ++++++++++ 65 files changed, 276 insertions(+), 4 deletions(-) diff --git a/server/scripts/generate-entities.ts b/server/scripts/generate-entities.ts index 7ab89a2ea..2c278cedf 100644 --- a/server/scripts/generate-entities.ts +++ b/server/scripts/generate-entities.ts @@ -57,6 +57,7 @@ import path from 'node:path'; import { ReferenceKind, UnderscoreNamingStrategy, + type Connection, type EntityMetadata, type EntityProperty, type ImportsResolver, @@ -655,6 +656,78 @@ export function RULE8_bindRepositories(metadata: EntityMetadata[]): void { } } +/** + * Rule 9 (metadata level; DB-dependent — see `collectImplicitUniqueIndexes` + * below for the half that actually queries the schema): every column-set an + * inline `UNIQUE(...)` table constraint (or a column-level `UNIQUE`) names + * gets a matching `uniques: [{ properties: [...] }]` entry. + * + * Why this is needed at all: SQLite backs every such constraint with an + * unnamed `sqlite_autoindex__` index, and `@mikro-orm/sql`'s own + * `SqliteSchemaHelper.getIndexes()` calls `isImplicitIndex(name)` — true for + * any index whose name starts with `sqlite_` — to filter exactly these out + * before `entity-generator` ever sees them (read directly out of + * `node_modules/@mikro-orm/sql/dialects/sqlite/SqliteSchemaHelper.js`, not + * assumed): "Ignore indexes with reserved names, e.g. autoindexes". A NAMED + * `CREATE UNIQUE INDEX` (`Trips.idx_trips_feed_token`, `Reservations. + * 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. + * + * `implicitUniques` is precomputed once per generator run and handed in as + * plain data (table name -> one string[] of column names per implicit unique + * index) specifically so this rule itself stays a pure, fixture-testable + * 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 set that is exactly the table's own primary key is skipped: a + * non-`INTEGER` (or composite) PRIMARY KEY gets the identical kind of + * unnamed `sqlite_autoindex_` entry a table-level `UNIQUE(...)` does + * (`AppSettings.key`, `Addons.id` — both `TEXT PRIMARY KEY`, confirmed by + * running this rule unfiltered against the real schema before adding this + * check), and it is already unique by construction and already `.primary()` + * on every one of its columns — a `uniques:` entry for it is pure noise, not + * a second constraint. + */ +export function RULE9_addImplicitUniqueConstraints( + metadata: EntityMetadata[], + implicitUniques: ReadonlyMap, +): void { + for (const meta of metadata) { + const indexes = implicitUniques.get(meta.tableName); + if (!indexes) continue; + const primaryKeys = new Set(meta.primaryKeys); + for (const columns of indexes) { + if (columns.length === primaryKeys.size && columns.every((column) => primaryKeys.has(column))) continue; + for (const column of columns) { + 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] }); + } + } +} + /** A scalar property whose literal default the renderer's own heuristic drops or mis-renders — see below. */ export interface DefaultFixup { className: string; @@ -731,8 +804,16 @@ export interface RuleFixups { retypedScalars: RetypedScalarFixup[]; } -/** Every rule above, composed into the single hook MikroORM calls. Order matters (see comments). */ -export function applyRules(metadata: EntityMetadata[], _platform: Platform): RuleFixups { +/** + * Every rule above, composed into the single hook MikroORM calls. Order + * matters (see comments). `implicitUniques` defaults to empty so a caller + * (the fixture tests) that doesn't care about Rule 9 need not pass it. + */ +export function applyRules( + metadata: EntityMetadata[], + _platform: Platform, + implicitUniques: ReadonlyMap = new Map(), +): RuleFixups { const retypedByRule1 = RULE1_fixUnknownScalarTypes(metadata); const jsonColumns = RULE1b_markJsonColumns(metadata); // Unlike Rule 1's/Rule 1b's retypes, a boolean column's own declaration @@ -751,10 +832,49 @@ export function applyRules(metadata: EntityMetadata[], _platform: Platform): Rul RULE6_renameInverseCollections(metadata); RULE7_dropNoActionRules(metadata); RULE8_bindRepositories(metadata); + RULE9_addImplicitUniqueConstraints(metadata, implicitUniques); const defaults = RULE_normalizeLiteralDefaults(metadata); return { joinColumns, defaults, timestamps, jsonColumns, retypedScalars: [...retypedByRule1, ...timestamps, ...jsonColumns] }; } +/** + * DB-dependent half of Rule 9: for every table name given, finds its implicit + * (`sqlite_autoindex_...`) UNIQUE indexes via the same two PRAGMAs + * `SqliteSchemaHelper.getIndexes()` itself uses (`index_list`, `index_info`), + * and returns their column sets in declared order (`index_info.seqno`). + * + * Kept separate from `RULE9_addImplicitUniqueConstraints` so that rule stays + * a pure function over plain data — this is the only piece of Rule 9 that + * touches a `Connection`, and it is exercised end-to-end (not unit-tested in + * isolation) by `generateEntities()`'s own tests against the real migrated + * schema. + */ +export async function collectImplicitUniqueIndexes( + connection: Connection, + tableNames: readonly string[], +): Promise> { + const result = new Map(); + for (const tableName of tableNames) { + const indexList = (await connection.execute(`pragma index_list(\`${tableName}\`)`, [], 'all')) as { + name: string; + unique: number; + }[]; + const autoUniques = indexList.filter((idx) => idx.unique && idx.name.startsWith('sqlite_autoindex_')); + if (autoUniques.length === 0) continue; + const perTable: string[][] = []; + for (const idx of autoUniques) { + const info = (await connection.execute(`pragma index_info(\`${idx.name}\`)`, [], 'all')) as { + seqno: number; + cid: number; + name: string; + }[]; + perTable.push([...info].sort((a, b) => a.seqno - b.seqno).map((c) => c.name)); + } + result.set(tableName, perTable); + } + return result; +} + // --------------------------------------------------------------------------- // onImport — resolves the two identifiers the rules above add to a file's // entityImports that are NOT sibling `*.entity.ts` classes: `DbTimestampType` @@ -1252,8 +1372,15 @@ export async function generateEntities(): Promise { skipTables: ['mikro_orm_migrations'], fileName: (className: string) => `${className}.entity`, onImport, - onProcessedMetadata: (metadata, platform) => { - fixups = applyRules(metadata, platform); + onProcessedMetadata: async (metadata, platform) => { + // Same connection the generator itself just introspected the schema + // through (`orm`, in scope from just above) — Rule 9 needs no + // second connection or a second migrated temp DB. + const implicitUniques = await collectImplicitUniqueIndexes( + orm.em.getConnection(), + metadata.map((meta) => meta.tableName), + ); + fixups = applyRules(metadata, platform, implicitUniques); }, }); diff --git a/server/src/db/entities/AssignmentParticipants.entity.ts b/server/src/db/entities/AssignmentParticipants.entity.ts index 34f3d5916..542cb1f8d 100644 --- a/server/src/db/entities/AssignmentParticipants.entity.ts +++ b/server/src/db/entities/AssignmentParticipants.entity.ts @@ -14,6 +14,7 @@ export class AssignmentParticipants { export const AssignmentParticipantsSchema = defineEntity({ class: AssignmentParticipants, repository: () => AssignmentParticipantsRepository, + uniques: [{ properties: ['assignment_id', 'user_id'] }], 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 a446c4a97..8bbc63a71 100644 --- a/server/src/db/entities/BudgetCategoryOrder.entity.ts +++ b/server/src/db/entities/BudgetCategoryOrder.entity.ts @@ -13,6 +13,7 @@ export class BudgetCategoryOrder { export const BudgetCategoryOrderSchema = defineEntity({ class: BudgetCategoryOrder, repository: () => BudgetCategoryOrderRepository, + uniques: [{ properties: ['trip_id', '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 72e56d513..031f05b0c 100644 --- a/server/src/db/entities/BudgetItemMembers.entity.ts +++ b/server/src/db/entities/BudgetItemMembers.entity.ts @@ -16,6 +16,7 @@ export class BudgetItemMembers { export const BudgetItemMembersSchema = defineEntity({ class: BudgetItemMembers, repository: () => BudgetItemMembersRepository, + uniques: [{ properties: ['budget_item_id', 'user_id'] }], 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 89383d2cc..55398578e 100644 --- a/server/src/db/entities/BudgetItemPayers.entity.ts +++ b/server/src/db/entities/BudgetItemPayers.entity.ts @@ -15,6 +15,7 @@ export class BudgetItemPayers { export const BudgetItemPayersSchema = defineEntity({ class: BudgetItemPayers, repository: () => BudgetItemPayersRepository, + uniques: [{ properties: ['budget_item_id', 'user_id'] }], 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 308253a68..0c2ebe301 100644 --- a/server/src/db/entities/CollabMessageReactions.entity.ts +++ b/server/src/db/entities/CollabMessageReactions.entity.ts @@ -17,6 +17,7 @@ export class CollabMessageReactions { export const CollabMessageReactionsSchema = defineEntity({ class: CollabMessageReactions, repository: () => CollabMessageReactionsRepository, + uniques: [{ properties: ['message_id', 'user_id', '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 32ad47ba2..b81e69337 100644 --- a/server/src/db/entities/CollabPollVotes.entity.ts +++ b/server/src/db/entities/CollabPollVotes.entity.ts @@ -17,6 +17,7 @@ export class CollabPollVotes { export const CollabPollVotesSchema = defineEntity({ class: CollabPollVotes, repository: () => CollabPollVotesRepository, + uniques: [{ properties: ['poll_id', 'user_id', '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 bab1a78f6..136344124 100644 --- a/server/src/db/entities/CollectionMembers.entity.ts +++ b/server/src/db/entities/CollectionMembers.entity.ts @@ -18,6 +18,7 @@ export class CollectionMembers { export const CollectionMembersSchema = defineEntity({ class: CollectionMembers, repository: () => CollectionMembersRepository, + uniques: [{ properties: ['collection_id', 'user_id'] }], 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 c919ab985..e263fe949 100644 --- a/server/src/db/entities/CollectionPlaceRatings.entity.ts +++ b/server/src/db/entities/CollectionPlaceRatings.entity.ts @@ -17,6 +17,7 @@ export class CollectionPlaceRatings { export const CollectionPlaceRatingsSchema = defineEntity({ class: CollectionPlaceRatings, repository: () => CollectionPlaceRatingsRepository, + uniques: [{ properties: ['collection_place_id', 'user_id'] }], 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 2a0095669..69113ba94 100644 --- a/server/src/db/entities/DawarichVisitSuggestions.entity.ts +++ b/server/src/db/entities/DawarichVisitSuggestions.entity.ts @@ -48,6 +48,7 @@ export const DawarichVisitSuggestionsSchema = defineEntity({ properties: ['user_id', 'state'], }, ], + uniques: [{ properties: ['user_id', '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 53a2426c3..31c8fda36 100644 --- a/server/src/db/entities/Days.entity.ts +++ b/server/src/db/entities/Days.entity.ts @@ -34,6 +34,7 @@ export class Days { export const DaysSchema = defineEntity({ class: Days, repository: () => DaysRepository, + uniques: [{ properties: ['trip_id', '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 bda8b2f32..a44ccb98c 100644 --- a/server/src/db/entities/DocumentConnections.entity.ts +++ b/server/src/db/entities/DocumentConnections.entity.ts @@ -30,6 +30,7 @@ export class DocumentConnections { export const DocumentConnectionsSchema = defineEntity({ class: DocumentConnections, repository: () => DocumentConnectionsRepository, + uniques: [{ properties: ['trip_id', 'provider_id'] }], 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 479d001da..4ad691244 100644 --- a/server/src/db/entities/DocumentProviderFields.entity.ts +++ b/server/src/db/entities/DocumentProviderFields.entity.ts @@ -19,6 +19,7 @@ export class DocumentProviderFields { export const DocumentProviderFieldsSchema = defineEntity({ class: DocumentProviderFields, repository: () => DocumentProviderFieldsRepository, + uniques: [{ properties: ['provider_id', '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 b1331a750..1bc4b488c 100644 --- a/server/src/db/entities/FileLinks.entity.ts +++ b/server/src/db/entities/FileLinks.entity.ts @@ -30,6 +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: { id: p.integer().primary(), diff --git a/server/src/db/entities/HiddenCountries.entity.ts b/server/src/db/entities/HiddenCountries.entity.ts index 7e065672c..67aabc26b 100644 --- a/server/src/db/entities/HiddenCountries.entity.ts +++ b/server/src/db/entities/HiddenCountries.entity.ts @@ -14,6 +14,7 @@ export class HiddenCountries { export const HiddenCountriesSchema = defineEntity({ class: HiddenCountries, repository: () => HiddenCountriesRepository, + uniques: [{ properties: ['user_id', '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 db8d1b30a..0a44677ad 100644 --- a/server/src/db/entities/HiddenRegions.entity.ts +++ b/server/src/db/entities/HiddenRegions.entity.ts @@ -15,6 +15,7 @@ export class HiddenRegions { export const HiddenRegionsSchema = defineEntity({ class: HiddenRegions, repository: () => HiddenRegionsRepository, + uniques: [{ properties: ['user_id', '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 543676155..5c95d7bf6 100644 --- a/server/src/db/entities/IdempotencyKeys.entity.ts +++ b/server/src/db/entities/IdempotencyKeys.entity.ts @@ -17,6 +17,7 @@ export class IdempotencyKeys { export const IdempotencyKeysSchema = defineEntity({ class: IdempotencyKeys, repository: () => IdempotencyKeysRepository, + uniques: [{ properties: ['key', 'user_id', 'method', 'path'] }], properties: { key: p.text().primary(), user: () => p.manyToOne(Users).primary().ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/InviteTokens.entity.ts b/server/src/db/entities/InviteTokens.entity.ts index bc38f4e0b..46ad06cf8 100644 --- a/server/src/db/entities/InviteTokens.entity.ts +++ b/server/src/db/entities/InviteTokens.entity.ts @@ -20,6 +20,7 @@ export class InviteTokens { export const InviteTokensSchema = defineEntity({ class: InviteTokens, repository: () => InviteTokensRepository, + uniques: [{ properties: ['token'] }], properties: { id: p.integer().primary(), token: p.text(), diff --git a/server/src/db/entities/JourneyContributors.entity.ts b/server/src/db/entities/JourneyContributors.entity.ts index c68e56a6c..cd0f002af 100644 --- a/server/src/db/entities/JourneyContributors.entity.ts +++ b/server/src/db/entities/JourneyContributors.entity.ts @@ -17,6 +17,7 @@ export class JourneyContributors { export const JourneyContributorsSchema = defineEntity({ class: JourneyContributors, repository: () => JourneyContributorsRepository, + uniques: [{ properties: ['journey_id', 'user_id'] }], 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 250472800..c87089fec 100644 --- a/server/src/db/entities/JourneyEntryPhotos.entity.ts +++ b/server/src/db/entities/JourneyEntryPhotos.entity.ts @@ -16,6 +16,7 @@ export class JourneyEntryPhotos { export const JourneyEntryPhotosSchema = defineEntity({ class: JourneyEntryPhotos, repository: () => JourneyEntryPhotosRepository, + uniques: [{ properties: ['entry_id', 'journey_photo_id'] }], 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 eb862ca3e..3f5950f09 100644 --- a/server/src/db/entities/JourneyPhotos.entity.ts +++ b/server/src/db/entities/JourneyPhotos.entity.ts @@ -23,6 +23,7 @@ export class JourneyPhotos { export const JourneyPhotosSchema = defineEntity({ class: JourneyPhotos, repository: () => JourneyPhotosRepository, + uniques: [{ properties: ['journey_id', 'photo_id'] }], 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/JourneyShareTokens.entity.ts b/server/src/db/entities/JourneyShareTokens.entity.ts index d17930cfd..a5f751d82 100644 --- a/server/src/db/entities/JourneyShareTokens.entity.ts +++ b/server/src/db/entities/JourneyShareTokens.entity.ts @@ -21,6 +21,7 @@ export class JourneyShareTokens { export const JourneyShareTokensSchema = defineEntity({ class: JourneyShareTokens, repository: () => JourneyShareTokensRepository, + uniques: [{ properties: ['token'] }], properties: { id: p.integer().primary(), journey: () => p.oneToOne(Journeys).ref().deleteRule('cascade').hidden().unique('idx_journey_share_journey'), diff --git a/server/src/db/entities/JourneyTrips.entity.ts b/server/src/db/entities/JourneyTrips.entity.ts index dd487ba89..ea77543d3 100644 --- a/server/src/db/entities/JourneyTrips.entity.ts +++ b/server/src/db/entities/JourneyTrips.entity.ts @@ -15,6 +15,7 @@ export class JourneyTrips { export const JourneyTripsSchema = defineEntity({ class: JourneyTrips, repository: () => JourneyTripsRepository, + uniques: [{ properties: ['journey_id', 'trip_id'] }], 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 974a7834e..3c4a9729b 100644 --- a/server/src/db/entities/NotificationChannelPreferences.entity.ts +++ b/server/src/db/entities/NotificationChannelPreferences.entity.ts @@ -14,6 +14,7 @@ export class NotificationChannelPreferences { export const NotificationChannelPreferencesSchema = defineEntity({ class: NotificationChannelPreferences, repository: () => NotificationChannelPreferencesRepository, + uniques: [{ properties: ['user_id', '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/OauthClients.entity.ts b/server/src/db/entities/OauthClients.entity.ts index 952d0b48f..0f2fe4ff8 100644 --- a/server/src/db/entities/OauthClients.entity.ts +++ b/server/src/db/entities/OauthClients.entity.ts @@ -25,6 +25,7 @@ export class OauthClients { export const OauthClientsSchema = defineEntity({ class: OauthClients, repository: () => OauthClientsRepository, + uniques: [{ properties: ['client_id'] }], properties: { id: p.text().primary().nullable(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').nullable().hidden().index('idx_oauth_clients_user'), diff --git a/server/src/db/entities/OauthConsents.entity.ts b/server/src/db/entities/OauthConsents.entity.ts index fb967fcfa..c2f2eee12 100644 --- a/server/src/db/entities/OauthConsents.entity.ts +++ b/server/src/db/entities/OauthConsents.entity.ts @@ -17,6 +17,7 @@ export class OauthConsents { export const OauthConsentsSchema = defineEntity({ class: OauthConsents, repository: () => OauthConsentsRepository, + uniques: [{ properties: ['client_id', 'user_id'] }], properties: { id: p.integer().primary(), client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/OauthTokens.entity.ts b/server/src/db/entities/OauthTokens.entity.ts index dd127aa04..da94a8312 100644 --- a/server/src/db/entities/OauthTokens.entity.ts +++ b/server/src/db/entities/OauthTokens.entity.ts @@ -26,6 +26,10 @@ export class OauthTokens { export const OauthTokensSchema = defineEntity({ class: OauthTokens, repository: () => OauthTokensRepository, + uniques: [ + { properties: ['refresh_token_hash'] }, + { properties: ['access_token_hash'] }, + ], 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 877367e2c..b595c3a84 100644 --- a/server/src/db/entities/PackingCategoryAssignees.entity.ts +++ b/server/src/db/entities/PackingCategoryAssignees.entity.ts @@ -15,6 +15,7 @@ export class PackingCategoryAssignees { export const PackingCategoryAssigneesSchema = defineEntity({ class: PackingCategoryAssignees, repository: () => PackingCategoryAssigneesRepository, + uniques: [{ properties: ['trip_id', 'category_name', 'user_id'] }], 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 e551ed9ba..aa258c455 100644 --- a/server/src/db/entities/PackingItemContributors.entity.ts +++ b/server/src/db/entities/PackingItemContributors.entity.ts @@ -17,6 +17,7 @@ export class PackingItemContributors { export const PackingItemContributorsSchema = defineEntity({ class: PackingItemContributors, repository: () => PackingItemContributorsRepository, + uniques: [{ properties: ['item_id', 'user_id'] }], properties: { item: () => p.manyToOne(PackingItems).primary().ref().hidden(), item_id: p.integer().persist(false), diff --git a/server/src/db/entities/PasswordResetTokens.entity.ts b/server/src/db/entities/PasswordResetTokens.entity.ts index 5a52dddb7..31ec9cd18 100644 --- a/server/src/db/entities/PasswordResetTokens.entity.ts +++ b/server/src/db/entities/PasswordResetTokens.entity.ts @@ -17,6 +17,7 @@ export class PasswordResetTokens { export const PasswordResetTokensSchema = defineEntity({ class: PasswordResetTokens, repository: () => PasswordResetTokensRepository, + uniques: [{ properties: ['token_hash'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_prt_user'), diff --git a/server/src/db/entities/PhotoProviderFields.entity.ts b/server/src/db/entities/PhotoProviderFields.entity.ts index 24688d55b..9d4dbc3cd 100644 --- a/server/src/db/entities/PhotoProviderFields.entity.ts +++ b/server/src/db/entities/PhotoProviderFields.entity.ts @@ -21,6 +21,7 @@ export class PhotoProviderFields { export const PhotoProviderFieldsSchema = defineEntity({ class: PhotoProviderFields, repository: () => PhotoProviderFieldsRepository, + uniques: [{ properties: ['provider_id', '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 963bb7703..0d3567179 100644 --- a/server/src/db/entities/PlaceRatings.entity.ts +++ b/server/src/db/entities/PlaceRatings.entity.ts @@ -17,6 +17,7 @@ export class PlaceRatings { export const PlaceRatingsSchema = defineEntity({ class: PlaceRatings, repository: () => PlaceRatingsRepository, + uniques: [{ properties: ['place_id', 'user_id'] }], 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/PluginEgressHosts.entity.ts b/server/src/db/entities/PluginEgressHosts.entity.ts index 0dc3c4775..31a8bad3a 100644 --- a/server/src/db/entities/PluginEgressHosts.entity.ts +++ b/server/src/db/entities/PluginEgressHosts.entity.ts @@ -11,6 +11,7 @@ export class PluginEgressHosts { export const PluginEgressHostsSchema = defineEntity({ class: PluginEgressHosts, repository: () => PluginEgressHostsRepository, + uniques: [{ properties: ['plugin_id', 'host'] }], properties: { id: p.integer().primary(), plugin_id: p.text().index('idx_plugin_egress_hosts_plugin'), diff --git a/server/src/db/entities/PluginEntityMetadata.entity.ts b/server/src/db/entities/PluginEntityMetadata.entity.ts index ec3c7db17..b80e2ba56 100644 --- a/server/src/db/entities/PluginEntityMetadata.entity.ts +++ b/server/src/db/entities/PluginEntityMetadata.entity.ts @@ -20,6 +20,7 @@ export const PluginEntityMetadataSchema = defineEntity({ properties: ['plugin_id', 'entity_type', 'entity_id'], }, ], + uniques: [{ properties: ['plugin_id', 'entity_type', 'entity_id', 'key'] }], properties: { id: p.integer().primary(), plugin_id: p.text(), diff --git a/server/src/db/entities/PluginScheduledTasks.entity.ts b/server/src/db/entities/PluginScheduledTasks.entity.ts index 6e392f283..974485e6a 100644 --- a/server/src/db/entities/PluginScheduledTasks.entity.ts +++ b/server/src/db/entities/PluginScheduledTasks.entity.ts @@ -14,6 +14,7 @@ export class PluginScheduledTasks { export const PluginScheduledTasksSchema = defineEntity({ class: PluginScheduledTasks, repository: () => PluginScheduledTasksRepository, + uniques: [{ properties: ['plugin_id', 'name'] }], properties: { id: p.integer().primary(), plugin_id: p.text(), diff --git a/server/src/db/entities/PluginSettingsFields.entity.ts b/server/src/db/entities/PluginSettingsFields.entity.ts index c582c07a5..7edc2e779 100644 --- a/server/src/db/entities/PluginSettingsFields.entity.ts +++ b/server/src/db/entities/PluginSettingsFields.entity.ts @@ -21,6 +21,7 @@ export class PluginSettingsFields { export const PluginSettingsFieldsSchema = defineEntity({ class: PluginSettingsFields, repository: () => PluginSettingsFieldsRepository, + uniques: [{ properties: ['plugin_id', 'field_key'] }], properties: { id: p.integer().primary(), plugin_id: p.text(), diff --git a/server/src/db/entities/PluginUserErasureQueue.entity.ts b/server/src/db/entities/PluginUserErasureQueue.entity.ts index 75ae8e4f2..30445bbb1 100644 --- a/server/src/db/entities/PluginUserErasureQueue.entity.ts +++ b/server/src/db/entities/PluginUserErasureQueue.entity.ts @@ -11,6 +11,7 @@ export class PluginUserErasureQueue { export const PluginUserErasureQueueSchema = defineEntity({ class: PluginUserErasureQueue, repository: () => PluginUserErasureQueueRepository, + uniques: [{ properties: ['plugin_id', 'user_id'] }], properties: { id: p.integer().primary(), plugin_id: p.text().index('idx_plugin_erasure_plugin'), diff --git a/server/src/db/entities/ReservationDayPositions.entity.ts b/server/src/db/entities/ReservationDayPositions.entity.ts index dc05279f7..f95bfd935 100644 --- a/server/src/db/entities/ReservationDayPositions.entity.ts +++ b/server/src/db/entities/ReservationDayPositions.entity.ts @@ -15,6 +15,7 @@ export class ReservationDayPositions { export const ReservationDayPositionsSchema = defineEntity({ class: ReservationDayPositions, repository: () => ReservationDayPositionsRepository, + uniques: [{ properties: ['reservation_id', 'day_id'] }], 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 b9c72aa5b..baa46edfd 100644 --- a/server/src/db/entities/ReservationTravelers.entity.ts +++ b/server/src/db/entities/ReservationTravelers.entity.ts @@ -14,6 +14,7 @@ export class ReservationTravelers { export const ReservationTravelersSchema = defineEntity({ class: ReservationTravelers, repository: () => ReservationTravelersRepository, + uniques: [{ properties: ['reservation_id', 'user_id'] }], 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 1f33aa3f1..9ff933b4a 100644 --- a/server/src/db/entities/RoadtripDayBoundaries.entity.ts +++ b/server/src/db/entities/RoadtripDayBoundaries.entity.ts @@ -18,6 +18,7 @@ export class RoadtripDayBoundaries { export const RoadtripDayBoundariesSchema = defineEntity({ class: RoadtripDayBoundaries, repository: () => RoadtripDayBoundariesRepository, + uniques: [{ properties: ['trip_id', '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 508ff5be8..374b092fb 100644 --- a/server/src/db/entities/RoadtripPreferences.entity.ts +++ b/server/src/db/entities/RoadtripPreferences.entity.ts @@ -13,6 +13,7 @@ export class RoadtripPreferences { export const RoadtripPreferencesSchema = defineEntity({ class: RoadtripPreferences, repository: () => RoadtripPreferencesRepository, + uniques: [{ properties: ['trip_id', '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 58fad25a6..55d0995d8 100644 --- a/server/src/db/entities/SchoolHolidayRegions.entity.ts +++ b/server/src/db/entities/SchoolHolidayRegions.entity.ts @@ -15,6 +15,7 @@ export class SchoolHolidayRegions { export const SchoolHolidayRegionsSchema = defineEntity({ class: SchoolHolidayRegions, repository: () => SchoolHolidayRegionsRepository, + uniques: [{ properties: ['country', '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 87fd3f431..0654633b8 100644 --- a/server/src/db/entities/Settings.entity.ts +++ b/server/src/db/entities/Settings.entity.ts @@ -13,6 +13,7 @@ export class Settings { export const SettingsSchema = defineEntity({ class: Settings, repository: () => SettingsRepository, + uniques: [{ properties: ['user_id', 'key'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/ShareTokens.entity.ts b/server/src/db/entities/ShareTokens.entity.ts index cf81cb285..089001c5a 100644 --- a/server/src/db/entities/ShareTokens.entity.ts +++ b/server/src/db/entities/ShareTokens.entity.ts @@ -23,6 +23,7 @@ export class ShareTokens { export const ShareTokensSchema = defineEntity({ class: ShareTokens, repository: () => ShareTokensRepository, + uniques: [{ properties: ['token'] }], properties: { id: p.integer().primary(), trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/TodoCategoryAssignees.entity.ts b/server/src/db/entities/TodoCategoryAssignees.entity.ts index dccd2bd17..53e7f5150 100644 --- a/server/src/db/entities/TodoCategoryAssignees.entity.ts +++ b/server/src/db/entities/TodoCategoryAssignees.entity.ts @@ -15,6 +15,7 @@ export class TodoCategoryAssignees { export const TodoCategoryAssigneesSchema = defineEntity({ class: TodoCategoryAssignees, repository: () => TodoCategoryAssigneesRepository, + uniques: [{ properties: ['trip_id', 'category_name', 'user_id'] }], 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 c50ff84b3..66e02f67b 100644 --- a/server/src/db/entities/TripAlbumLinks.entity.ts +++ b/server/src/db/entities/TripAlbumLinks.entity.ts @@ -24,6 +24,7 @@ export class TripAlbumLinks { export const TripAlbumLinksSchema = defineEntity({ class: TripAlbumLinks, repository: () => TripAlbumLinksRepository, + uniques: [{ properties: ['trip_id', 'user_id', '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 3c84083fe..7c5e7bcdb 100644 --- a/server/src/db/entities/TripDocumentLinks.entity.ts +++ b/server/src/db/entities/TripDocumentLinks.entity.ts @@ -52,6 +52,7 @@ export const TripDocumentLinksSchema = defineEntity({ where: 'webhook_token IS NOT NULL', properties: ['webhook_token'], }, + { properties: ['trip_id', 'connection_id', '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 09a76b35b..c814a4e31 100644 --- a/server/src/db/entities/TripInviteTokens.entity.ts +++ b/server/src/db/entities/TripInviteTokens.entity.ts @@ -18,6 +18,7 @@ export class TripInviteTokens { export const TripInviteTokensSchema = defineEntity({ class: TripInviteTokens, repository: () => TripInviteTokensRepository, + uniques: [{ properties: ['token'] }, { properties: ['trip_id'] }], 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 1d2119afe..a6570a587 100644 --- a/server/src/db/entities/TripMembers.entity.ts +++ b/server/src/db/entities/TripMembers.entity.ts @@ -18,6 +18,7 @@ export class TripMembers { export const TripMembersSchema = defineEntity({ class: TripMembers, repository: () => TripMembersRepository, + uniques: [{ properties: ['trip_id', 'user_id'] }], 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 e82a29746..7089bce7d 100644 --- a/server/src/db/entities/TripPhotos.entity.ts +++ b/server/src/db/entities/TripPhotos.entity.ts @@ -23,6 +23,7 @@ export class TripPhotos { export const TripPhotosSchema = defineEntity({ class: TripPhotos, repository: () => TripPhotosRepository, + uniques: [{ properties: ['trip_id', 'user_id', 'photo_id'] }], 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 fabcb0e58..bd8646d3f 100644 --- a/server/src/db/entities/UserNoticeDismissals.entity.ts +++ b/server/src/db/entities/UserNoticeDismissals.entity.ts @@ -14,6 +14,7 @@ export class UserNoticeDismissals { export const UserNoticeDismissalsSchema = defineEntity({ class: UserNoticeDismissals, repository: () => UserNoticeDismissalsRepository, + uniques: [{ properties: ['user_id', '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/Users.entity.ts b/server/src/db/entities/Users.entity.ts index 986c0456b..565c26ef5 100644 --- a/server/src/db/entities/Users.entity.ts +++ b/server/src/db/entities/Users.entity.ts @@ -201,6 +201,8 @@ export const UsersSchema = defineEntity({ where: 'feed_token IS NOT NULL', properties: ['feed_token'], }, + { properties: ['email'] }, + { properties: ['username'] }, ], properties: { id: p.integer().primary(), diff --git a/server/src/db/entities/VacayCompanyHolidays.entity.ts b/server/src/db/entities/VacayCompanyHolidays.entity.ts index 62242e0fd..733aa0135 100644 --- a/server/src/db/entities/VacayCompanyHolidays.entity.ts +++ b/server/src/db/entities/VacayCompanyHolidays.entity.ts @@ -13,6 +13,7 @@ export class VacayCompanyHolidays { export const VacayCompanyHolidaysSchema = defineEntity({ class: VacayCompanyHolidays, repository: () => VacayCompanyHolidaysRepository, + uniques: [{ properties: ['plan_id', '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 70407d7c4..051a91528 100644 --- a/server/src/db/entities/VacayEntries.entity.ts +++ b/server/src/db/entities/VacayEntries.entity.ts @@ -18,6 +18,7 @@ export class VacayEntries { export const VacayEntriesSchema = defineEntity({ class: VacayEntries, repository: () => VacayEntriesRepository, + uniques: [{ properties: ['user_id', 'plan_id', '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 d8cd3389e..efae954a3 100644 --- a/server/src/db/entities/VacayPlanMembers.entity.ts +++ b/server/src/db/entities/VacayPlanMembers.entity.ts @@ -17,6 +17,7 @@ export class VacayPlanMembers { export const VacayPlanMembersSchema = defineEntity({ class: VacayPlanMembers, repository: () => VacayPlanMembersRepository, + uniques: [{ properties: ['plan_id', 'user_id'] }], 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 04434e44f..0310762c1 100644 --- a/server/src/db/entities/VacayPlans.entity.ts +++ b/server/src/db/entities/VacayPlans.entity.ts @@ -35,6 +35,7 @@ export class VacayPlans { export const VacayPlansSchema = defineEntity({ class: VacayPlans, repository: () => VacayPlansRepository, + uniques: [{ properties: ['owner_id'] }], 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 4b0629f2e..b5a63fa01 100644 --- a/server/src/db/entities/VacayShares.entity.ts +++ b/server/src/db/entities/VacayShares.entity.ts @@ -16,6 +16,7 @@ export class VacayShares { export const VacaySharesSchema = defineEntity({ class: VacayShares, repository: () => VacaySharesRepository, + uniques: [{ properties: ['owner_id', 'user_id'] }], 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 7ed465809..47fafe0f7 100644 --- a/server/src/db/entities/VacayUserColors.entity.ts +++ b/server/src/db/entities/VacayUserColors.entity.ts @@ -15,6 +15,7 @@ export class VacayUserColors { export const VacayUserColorsSchema = defineEntity({ class: VacayUserColors, repository: () => VacayUserColorsRepository, + uniques: [{ properties: ['user_id', 'plan_id'] }], 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 081238611..4ed504bda 100644 --- a/server/src/db/entities/VacayUserYears.entity.ts +++ b/server/src/db/entities/VacayUserYears.entity.ts @@ -17,6 +17,7 @@ export class VacayUserYears { export const VacayUserYearsSchema = defineEntity({ class: VacayUserYears, repository: () => VacayUserYearsRepository, + uniques: [{ properties: ['user_id', 'plan_id', '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 ab6abb4c8..62489cf36 100644 --- a/server/src/db/entities/VacayYears.entity.ts +++ b/server/src/db/entities/VacayYears.entity.ts @@ -12,6 +12,7 @@ export class VacayYears { export const VacayYearsSchema = defineEntity({ class: VacayYears, repository: () => VacayYearsRepository, + uniques: [{ properties: ['plan_id', '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 e7d302d3e..fdfe0ed2f 100644 --- a/server/src/db/entities/VisitedCountries.entity.ts +++ b/server/src/db/entities/VisitedCountries.entity.ts @@ -15,6 +15,7 @@ export class VisitedCountries { export const VisitedCountriesSchema = defineEntity({ class: VisitedCountries, repository: () => VisitedCountriesRepository, + uniques: [{ properties: ['user_id', '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 46ee4a595..b9e41aaf4 100644 --- a/server/src/db/entities/VisitedRegions.entity.ts +++ b/server/src/db/entities/VisitedRegions.entity.ts @@ -16,6 +16,7 @@ export class VisitedRegions { export const VisitedRegionsSchema = defineEntity({ class: VisitedRegions, repository: () => VisitedRegionsRepository, + uniques: [{ properties: ['user_id', 'region_code'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), diff --git a/server/src/db/entities/WebauthnChallenges.entity.ts b/server/src/db/entities/WebauthnChallenges.entity.ts index 7f2e29988..4fef490d7 100644 --- a/server/src/db/entities/WebauthnChallenges.entity.ts +++ b/server/src/db/entities/WebauthnChallenges.entity.ts @@ -16,6 +16,7 @@ export class WebauthnChallenges { export const WebauthnChallengesSchema = defineEntity({ class: WebauthnChallenges, repository: () => WebauthnChallengesRepository, + uniques: [{ properties: ['challenge'] }], properties: { id: p.integer().primary(), challenge: p.text(), diff --git a/server/src/db/entities/WebauthnCredentials.entity.ts b/server/src/db/entities/WebauthnCredentials.entity.ts index e82dbd3d9..a228a68d4 100644 --- a/server/src/db/entities/WebauthnCredentials.entity.ts +++ b/server/src/db/entities/WebauthnCredentials.entity.ts @@ -22,6 +22,7 @@ export class WebauthnCredentials { export const WebauthnCredentialsSchema = defineEntity({ class: WebauthnCredentials, repository: () => WebauthnCredentialsRepository, + uniques: [{ properties: ['credential_id'] }], properties: { id: p.integer().primary(), user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_webauthn_credentials_user'), diff --git a/server/tests/unit/db/generate-entities.test.ts b/server/tests/unit/db/generate-entities.test.ts index 845cd60f5..b148e939c 100644 --- a/server/tests/unit/db/generate-entities.test.ts +++ b/server/tests/unit/db/generate-entities.test.ts @@ -20,6 +20,7 @@ import { RULE6_renameInverseCollections, RULE7_dropNoActionRules, RULE8_bindRepositories, + RULE9_addImplicitUniqueConstraints, RULE_normalizeLiteralDefaults, applyTextPasses, checkEntities, @@ -414,6 +415,67 @@ 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', () => { + 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']]]]); + RULE9_addImplicitUniqueConstraints([meta], implicitUniques); + expect(meta.uniques).toEqual([{ properties: ['user_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]); + const implicitUniques = new Map([['addons', [['id']]]]); + RULE9_addImplicitUniqueConstraints([meta], implicitUniques); + expect(meta.uniques).toEqual([]); + }); + + it('RULE9-003: a column set that only partially overlaps the primary key (different size) is NOT treated as the PK and is kept', () => { + const id = fixtureProp({ name: 'id', primary: true }); + const tripId = fixtureProp({ name: 'trip_id', primary: false }); + const dayNumber = fixtureProp({ name: 'day_number', primary: false }); + const meta = fixtureMeta('Days', 'days', [id, tripId, dayNumber]); + const implicitUniques = new Map([['days', [['trip_id', 'day_number']]]]); + RULE9_addImplicitUniqueConstraints([meta], implicitUniques); + expect(meta.uniques).toEqual([{ properties: ['trip_id', 'day_number'] }]); + }); + + it('RULE9-004: a table absent from the map (no implicit unique index at all) is left untouched', () => { + const meta = fixtureMeta('Days', 'days', [fixtureProp({ name: 'id', primary: true })]); + RULE9_addImplicitUniqueConstraints([meta], new Map()); + expect(meta.uniques).toEqual([]); + }); + + it('RULE9-005: multiple implicit unique indexes on one table each get their own entry', () => { + const a = fixtureProp({ name: 'file_id' }); + const b = fixtureProp({ name: 'place_id' }); + const c = fixtureProp({ name: 'assignment_id' }); + const meta = fixtureMeta('FileLinks', 'file_links', [a, b, c]); + const implicitUniques = new Map([ + [ + 'file_links', + [ + ['file_id', 'place_id'], + ['file_id', 'assignment_id'], + ], + ], + ]); + RULE9_addImplicitUniqueConstraints([meta], implicitUniques); + expect(meta.uniques).toEqual([{ properties: ['file_id', 'place_id'] }, { properties: ['file_id', 'assignment_id'] }]); + }); + + it('RULE9-006: throws, naming the table/columns/entity, when an implicit index names a column with no matching property (I3)', () => { + const meta = fixtureMeta('Settings', 'settings', [fixtureProp({ name: 'key' })]); + const implicitUniques = new Map([['settings', [['user_id', 'key']]]]); + expect(() => RULE9_addImplicitUniqueConstraints([meta], implicitUniques)).toThrow( + /settings\(user_id, key\).*"user_id".*Settings/s, + ); + }); +}); + describe('RULE_normalizeLiteralDefaults', () => { it('DEFAULTS-001: a NOT NULL literal default is captured and defaultRaw cleared', () => { const p = fixtureProp({ name: 'sort_order', type: 'integer', nullable: false, default: 0, defaultRaw: '0' }); @@ -993,6 +1055,20 @@ describe('generateEntities — validation diff against the five reference entiti } expect(offenders).toEqual([]); }, 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', + async () => { + const { files } = await generateEntities(); + const settings = files.get('Settings.entity.ts'); + expect(settings).toContain("uniques: [{ properties: ['user_id', 'key'] }],"); + const appSettings = files.get('AppSettings.entity.ts'); + expect(appSettings).not.toMatch(/uniques:/); + const addons = files.get('Addons.entity.ts'); + expect(addons).not.toMatch(/uniques:/); + }, + 30_000, + ); }); describe('PENDING_ENTITIES ratchet (I7)', () => {