fix(db): generator captures inline UNIQUE constraints

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.
This commit is contained in:
jubnl
2026-09-22 14:13:09 +02:00
parent 6db91c8bf3
commit 310a3b0ad3
65 changed files with 276 additions and 4 deletions
+131 -4
View File
@@ -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_<table>_<n>` 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<string, string[][]>,
): 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<string, string[][]> = 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<Map<string, string[][]>> {
const result = new Map<string, string[][]>();
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<GenerateResult> {
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);
},
});
@@ -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'),
@@ -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),
@@ -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'),
@@ -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'),
@@ -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'),
@@ -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(),
@@ -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(),
@@ -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'),
@@ -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(),
+1
View File
@@ -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'),
@@ -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'),
@@ -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(),
@@ -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(),
@@ -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'),
@@ -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'),
@@ -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(),
@@ -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(),
@@ -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),
@@ -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'),
@@ -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'),
@@ -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'),
@@ -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'),
@@ -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'),
@@ -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'),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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),
@@ -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'),
@@ -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(),
@@ -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'),
@@ -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'),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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'),
@@ -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),
@@ -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'),
@@ -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',
@@ -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),
@@ -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),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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'),
@@ -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(),
@@ -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(),
@@ -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'),
@@ -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'),
@@ -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),
+2
View File
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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'),
@@ -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)', () => {