mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user