drizzle-team / drizzle-orm

Headless TypeScript ORM with a head. Runs on Node, Bun and Deno. Lives on the Edge and yes, it's a JavaScript ORM too 😅
https://orm.drizzle.team
Apache License 2.0
21.57k stars 490 forks source link

[BUG]: Error: There is not enough information to infer relation for many-to-many relationship #2347

Closed recoskyler closed 1 month ago

recoskyler commented 1 month ago

What version of drizzle-orm are you using?

0.30.10

What version of drizzle-kit are you using?

0.21.2

Describe the Bug

When Drizzle Studio is started after generating and running migrations on a freshly-created PostgreSQL database, I receive the following error alongside an infinite loading indicator on the Studio:

Error: There is not enough information to infer relation "__public__.categories.products"
    at normalizeRelation (/workspaces/api/node_modules/src/relations.ts:624:8)
    at /workspaces/api/node_modules/drizzle-kit/bin.cjs:112037:72
    at Array.map (<anonymous>)
    at /workspaces/api/node_modules/drizzle-kit/bin.cjs:112036:46
    at Array.map (<anonymous>)
    at extractRelations (/workspaces/api/node_modules/drizzle-kit/bin.cjs:112035:61)
    at /workspaces/api/node_modules/drizzle-kit/bin.cjs:112183:24
    at dispatch (/workspaces/api/node_modules/drizzle-kit/bin.cjs:109782:27)
    at /workspaces/api/node_modules/drizzle-kit/bin.cjs:109783:24
    at /workspaces/api/node_modules/drizzle-kit/bin.cjs:109450:15

What I have tried

schema.ts

The schema is pretty simple when it comes to the problematic part. There are products and categories. Each product can have multiple categories vice-versa.

//* Products

export const products = pgTable(
  "products",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    enabled: boolean("enabled").notNull().default(true),
    createdAt: timestamp("created_at")
      .notNull()
      .default(sql`CURRENT_TIMESTAMP`),
    ean: varchar("ean", { length: 128 }).unique().notNull(),
    measurement: doublePrecision("measurement").notNull().default(1),
    isExactMeasurement: boolean("is_exact_measurement").notNull().default(true),
    uom: unitOfMeasurementEnum("uom").notNull().default("pc"),
    updatedAt: timestamp("updated_at", {
      mode: "date",
      precision: 3,
    }).$onUpdate(() => new Date()),
  },
  (table) => ({
    enabledIdx: index("product_enabled_idx").on(table.enabled),
    eanIdx: index("product_ean_idx").on(table.ean),
    measurementIdx: index("product_measurement_idx").on(table.measurement),
    uomIdx: index("product_uom_idx").on(table.uom),
    isExactMeasurementIdx: index("product_is_exact_measurement_idx").on(
      table.isExactMeasurement,
    ),
  }),
);

export const productRelations = relations(products, ({ many }) => ({
  categories: many(categories),
  subNames: many(productSubNames, {
    relationName: "product-sub-names",
  }),
  prices: many(prices, {
    relationName: "product-prices",
  }),
  media: many(media, {
    relationName: "product-media",
  }),
  names: many(productNames, {
    relationName: "product-names",
  }),
  stores: many(stores, {
    relationName: "store-products",
  }),
}));

//* Product-Category

export const productCategory = pgTable(
  "product_category",
  {
    productId: uuid("product_id")
      .notNull()
      .references(() => products.id),
    categoryId: uuid("category_id")
      .notNull()
      .references(() => categories.id),
  },
  (table) => ({
    pk: primaryKey({ columns: [table.productId, table.categoryId] }),
  }),
);

export const productCategoryRelations = relations(
  productCategory,
  ({ one }) => ({
    product: one(products, {
      fields: [productCategory.productId],
      references: [products.id],
    }),
    category: one(categories, {
      fields: [productCategory.categoryId],
      references: [categories.id],
    }),
  }),
);

//* Categories

export const categories = pgTable(
  "categories",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    enabled: boolean("enabled").notNull().default(true),
    storeId: uuid("store_id")
      .notNull()
      .references(() => stores.id),
    parentCategoryId: uuid("parent_category_id"),
    thumbnailUrl: varchar("thumbnail_url", { length: 256 }),
  },
  (table) => ({
    enabledIdx: index("category_enabled_idx").on(table.enabled),
    storeIdx: index("category_store_idx").on(table.storeId),
  }),
);

export const categoryRelations = relations(categories, ({ one, many }) => ({
  parent: one(categories, {
    fields: [categories.parentCategoryId],
    references: [categories.id],
    relationName: "sub-categories",
  }),
  subCategories: many(categories, {
    relationName: "sub-categories",
  }),
  names: many(categoryNames, {
    relationName: "category-names",
  }),
  products: many(products),
  store: one(stores, {
    fields: [categories.storeId],
    references: [stores.id],
    relationName: "store-categories",
  }),
}));

And, here's the full schema.ts just in case:

import { relations, sql } from "drizzle-orm";
import {
  uuid,
  pgTable,
  boolean,
  index,
  varchar,
  text,
  timestamp,
  integer,
  pgEnum,
  doublePrecision,
  primaryKey,
} from "drizzle-orm/pg-core";

//* Enums

export const mediaTypeEnum = pgEnum("media_type", [
  "image",
  "video",
  "link",
  "badge",
  "chip",
  "thumbnail",
  "alt_image",
  "alt_video",
  "alt_link",
  "alt_badge",
  "alt_chip",
  "alt_thumbnail",
]);

export const unitOfMeasurementEnum = pgEnum("unit_of_measurement", [
  "mg",
  "g",
  "kg",
  "ml",
  "cl",
  "l",
  "oz",
  "lb",
  "pc",
]);

export const roleEnum = pgEnum("role", ["admin", "moderator", "user"]);

const uomEnumVals = unitOfMeasurementEnum.enumValues;
export type UoMType = (typeof uomEnumVals)[number];

//* Stores

export const stores = pgTable(
  "stores",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    enabled: boolean("enabled").notNull().default(true),
    createdAt: timestamp("created_at")
      .notNull()
      .default(sql`CURRENT_TIMESTAMP`),
    name: varchar("name", { length: 128 }).notNull(),
    updatedAt: timestamp("updated_at", {
      mode: "date",
      precision: 3,
    }).$onUpdate(() => new Date()),
    iconUrl: varchar("icon_url", { length: 256 }),
    thumbnailUrl: varchar("thumbnail_url", { length: 256 }),
    website: varchar("website", { length: 256 }),
    countryCode: varchar("country_code", { length: 2 }).notNull().default("EE"),
    currency: varchar("currency", { length: 3 }).notNull().default("EUR"),
    membershipName: varchar("membership_name", { length: 64 }),
  },
  (table) => ({
    enabledIdx: index("store_enabled_idx").on(table.enabled),
    nameIdx: index("store_name_idx").on(table.name),
  }),
);

export const storeRelations = relations(stores, ({ many }) => ({
  products: many(products, {
    relationName: "store-products",
  }),
  prices: many(prices, {
    relationName: "store-prices",
  }),
  media: many(media, {
    relationName: "store-media",
  }),
  categories: many(categories, {
    relationName: "store-categories",
  }),
  productNames: many(productNames, {
    relationName: "store-product-names",
  }),
  productSubNames: many(productSubNames, {
    relationName: "store-product-sub-names",
  }),
}));

//* Prices

export const prices = pgTable(
  "prices",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    productId: uuid("product_id")
      .notNull()
      .references(() => products.id),
    storeId: uuid("store_id")
      .notNull()
      .references(() => stores.id),
    createdAt: timestamp("created_at")
      .notNull()
      .default(sql`CURRENT_TIMESTAMP`),
    amount: integer("amount").notNull(),
    currency: varchar("currency", { length: 3 }).notNull(),
    updatedAt: timestamp("updated_at", {
      mode: "date",
      precision: 3,
    }).$onUpdate(() => new Date()),
    discountedAmount: integer("discounted_amount"),
    memberDiscountedAmount: integer("member_discounted_amount"),
    isInStock: boolean("is_in_stock").default(true),
    isCurrentPrice: boolean("is_current_price").default(true),
    uom: unitOfMeasurementEnum("uom").default("pc"),
    campaignStart: timestamp("campaign_start"),
    campaignEnd: timestamp("campaign_end"),
  },
  (table) => ({
    amountIdx: index("price_amount_idx").on(table.amount),
    discountedAmountIdx: index("price_discounted_amount_idx").on(
      table.discountedAmount,
    ),
    memberDiscountedAmountIdx: index("price_member_discounted_amount_idx").on(
      table.memberDiscountedAmount,
    ),
    isInStockIdx: index("price_is_in_stock_idx").on(table.isInStock),
    isCurrentPriceIdx: index("price_is_current_price_idx").on(
      table.isCurrentPrice,
    ),
    currencyIdx: index("price_currency_idx").on(table.currency),
  }),
);

export const priceRelations = relations(prices, ({ one }) => ({
  product: one(products, {
    fields: [prices.productId],
    references: [products.id],
    relationName: "product-prices",
  }),
  store: one(stores, {
    fields: [prices.storeId],
    references: [stores.id],
    relationName: "store-prices",
  }),
}));

//* Media

export const media = pgTable(
  "media",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    createdAt: timestamp("created_at")
      .notNull()
      .default(sql`CURRENT_TIMESTAMP`),
    productId: uuid("product_id")
      .notNull()
      .references(() => products.id),
    storeId: uuid("store_id")
      .notNull()
      .references(() => stores.id),
    type: mediaTypeEnum("type").notNull(),
    enabled: boolean("enabled").notNull().default(true),
    url: varchar("url", { length: 256 }).notNull(),
    updatedAt: timestamp("updated_at", {
      mode: "date",
      precision: 3,
    }).$onUpdate(() => new Date()),
  },
  (table) => ({
    type: index("media_type_idx").on(table.type),
    enabled: index("media_enabled_idx").on(table.enabled),
  }),
);

export const mediaRelations = relations(media, ({ one }) => ({
  product: one(products, {
    fields: [media.productId],
    references: [products.id],
    relationName: "product-media",
  }),
  store: one(stores, {
    fields: [media.storeId],
    references: [stores.id],
    relationName: "store-media",
  }),
}));

//* Products

export const products = pgTable(
  "products",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    enabled: boolean("enabled").notNull().default(true),
    createdAt: timestamp("created_at")
      .notNull()
      .default(sql`CURRENT_TIMESTAMP`),
    ean: varchar("ean", { length: 128 }).unique().notNull(),
    measurement: doublePrecision("measurement").notNull().default(1),
    isExactMeasurement: boolean("is_exact_measurement").notNull().default(true),
    uom: unitOfMeasurementEnum("uom").notNull().default("pc"),
    updatedAt: timestamp("updated_at", {
      mode: "date",
      precision: 3,
    }).$onUpdate(() => new Date()),
  },
  (table) => ({
    enabledIdx: index("product_enabled_idx").on(table.enabled),
    eanIdx: index("product_ean_idx").on(table.ean),
    measurementIdx: index("product_measurement_idx").on(table.measurement),
    uomIdx: index("product_uom_idx").on(table.uom),
    isExactMeasurementIdx: index("product_is_exact_measurement_idx").on(
      table.isExactMeasurement,
    ),
  }),
);

export const productRelations = relations(products, ({ many }) => ({
  categories: many(categories),
  subNames: many(productSubNames, {
    relationName: "product-sub-names",
  }),
  prices: many(prices, {
    relationName: "product-prices",
  }),
  media: many(media, {
    relationName: "product-media",
  }),
  names: many(productNames, {
    relationName: "product-names",
  }),
  stores: many(stores, {
    relationName: "store-products",
  }),
}));

//* Product-Category

export const productCategory = pgTable(
  "product_category",
  {
    productId: uuid("product_id")
      .notNull()
      .references(() => products.id),
    categoryId: uuid("category_id")
      .notNull()
      .references(() => categories.id),
  },
  (table) => ({
    pk: primaryKey({ columns: [table.productId, table.categoryId] }),
  }),
);

export const productCategoryRelations = relations(
  productCategory,
  ({ one }) => ({
    product: one(products, {
      fields: [productCategory.productId],
      references: [products.id],
    }),
    category: one(categories, {
      fields: [productCategory.categoryId],
      references: [categories.id],
    }),
  }),
);

//* Categories

export const categories = pgTable(
  "categories",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    enabled: boolean("enabled").notNull().default(true),
    storeId: uuid("store_id")
      .notNull()
      .references(() => stores.id),
    parentCategoryId: uuid("parent_category_id"),
    thumbnailUrl: varchar("thumbnail_url", { length: 256 }),
  },
  (table) => ({
    enabledIdx: index("category_enabled_idx").on(table.enabled),
    storeIdx: index("category_store_idx").on(table.storeId),
  }),
);

export const categoryRelations = relations(categories, ({ one, many }) => ({
  parent: one(categories, {
    fields: [categories.parentCategoryId],
    references: [categories.id],
    relationName: "sub-categories",
  }),
  subCategories: many(categories, {
    relationName: "sub-categories",
  }),
  names: many(categoryNames, {
    relationName: "category-names",
  }),
  products: many(products),
  store: one(stores, {
    fields: [categories.storeId],
    references: [stores.id],
    relationName: "store-categories",
  }),
}));

//* Job Logs

export const jobLogs = pgTable("job_logs", {
  id: uuid("id").primaryKey().defaultRandom(),
  tag: varchar("tag", { length: 32 }),
  startedAt: timestamp("started_at").notNull().default(new Date()),
  isSuccessful: boolean("is_successful").notNull().default(false),
  isFailed: boolean("is_failed").notNull().default(false),
  isRunning: boolean("is_running").notNull().default(true),
  completedAt: timestamp("completed_at"),
  storeId: uuid("store_id").references(() => stores.id),
  rawLogs: text("raw_logs"),
});

export const jobLogRelations = relations(jobLogs, ({ one }) => ({
  store: one(stores, {
    fields: [jobLogs.storeId],
    references: [stores.id],
    relationName: "store-job-logs",
  }),
}));

//* Product Names

export const productNames = pgTable(
  "product_names",
  {
    productId: uuid("product_id")
      .notNull()
      .references(() => products.id),
    storeId: uuid("store_id")
      .notNull()
      .references(() => stores.id),
    languageCode: varchar("language", { length: 2 }).notNull(),
    name: varchar("name", { length: 128 }).notNull(),
  },
  (table) => ({
    pk: primaryKey({
      columns: [table.productId, table.languageCode, table.storeId],
    }),
    nameIdx: index("product_name_idx").on(table.name),
    languageCode: index("product_name_lang_idx").on(table.languageCode),
  }),
);

export const productNameRelations = relations(productNames, ({ one }) => ({
  product: one(products, {
    fields: [productNames.productId],
    references: [products.id],
    relationName: "product-names",
  }),
  store: one(stores, {
    fields: [productNames.storeId],
    references: [stores.id],
    relationName: "store-product-names",
  }),
}));

//* Product Sub-Names

export const productSubNames = pgTable(
  "product_names",
  {
    productId: uuid("product_id")
      .notNull()
      .references(() => products.id),
    storeId: uuid("store_id")
      .notNull()
      .references(() => stores.id),
    languageCode: varchar("language", { length: 2 }).notNull(),
    subName: varchar("sub_name", { length: 128 }).notNull(),
  },
  (table) => ({
    pk: primaryKey({
      columns: [table.productId, table.languageCode, table.storeId],
    }),
    subNameIdx: index("product_sub_name_idx").on(table.subName),
    languageCode: index("product_sub_name_lang_idx").on(table.languageCode),
    storeIdx: index("product_sub_name_store_idx").on(table.storeId),
  }),
);

export const productSubNamesRelations = relations(
  productSubNames,
  ({ one }) => ({
    product: one(products, {
      fields: [productSubNames.productId],
      references: [products.id],
      relationName: "product-sub-names",
    }),
    store: one(stores, {
      fields: [productSubNames.storeId],
      references: [stores.id],
      relationName: "store-product-sub-names",
    }),
  }),
);

//* Category Names

export const categoryNames = pgTable(
  "category_names",
  {
    categoryId: uuid("category_id")
      .notNull()
      .references(() => categories.id),
    languageCode: varchar("language", { length: 2 }).notNull(),
    name: varchar("name", { length: 128 }).notNull(),
  },
  (table) => ({
    pk: primaryKey({
      columns: [table.categoryId, table.languageCode, table.name],
    }),
    nameIdx: index("category_name_idx").on(table.name),
    languageCode: index("category_name_lang_idx").on(table.languageCode),
  }),
);

export const categoryNameRelations = relations(categoryNames, ({ one }) => ({
  category: one(categories, {
    fields: [categoryNames.categoryId],
    references: [categories.id],
    relationName: "category-names",
  }),
}));

//* Users and Sessions

export const users = pgTable(
  "users",
  {
    id: text("id").primaryKey(),
    role: roleEnum("role").notNull().default("user"),
    createdAt: timestamp("created_at")
      .notNull()
      .default(sql`CURRENT_TIMESTAMP`),
    email: varchar("email", { length: 256 }).unique().notNull(),
    password: varchar("password", { length: 256 }).notNull(),
    name: varchar("name", { length: 64 }),
    updatedAt: timestamp("updated_at", {
      mode: "date",
      precision: 3,
    }).$onUpdate(() => new Date()),
  },
  (table) => ({
    emailIdx: index("user_email_idx").on(table.email),
    roleIdx: index("user_role_idx").on(table.role),
  }),
);

export const sessions = pgTable("sessions", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() => users.id),
  expiresAt: timestamp("expires_at", {
    withTimezone: true,
    mode: "date",
  }).notNull(),
});

Here's the output of drizzle-kit generate:

> drizzle-kit generate

drizzle-kit: v0.21.2
drizzle-orm: v0.30.10

No config path provided, using default 'drizzle.config.ts'
Reading config file '/workspaces/api/drizzle.config.ts'
11 tables
categories 5 columns 2 indexes 1 fks
category_names 3 columns 2 indexes 1 fks
job_logs 9 columns 0 indexes 1 fks
media 8 columns 2 indexes 2 fks
prices 14 columns 6 indexes 2 fks
product_category 2 columns 0 indexes 2 fks
product_names 4 columns 3 indexes 2 fks
products 8 columns 5 indexes 0 fks
sessions 3 columns 0 indexes 1 fks
stores 11 columns 2 indexes 0 fks
users 7 columns 2 indexes 0 fks

0000_bla_bla.sql

DO $$ BEGIN
 CREATE TYPE "public"."media_type" AS ENUM('image', 'video', 'link', 'badge', 'chip', 'thumbnail', 'alt_image', 'alt_video', 'alt_link', 'alt_badge', 'alt_chip', 'alt_thumbnail');
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 CREATE TYPE "public"."role" AS ENUM('admin', 'moderator', 'user');
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 CREATE TYPE "public"."unit_of_measurement" AS ENUM('mg', 'g', 'kg', 'ml', 'cl', 'l', 'oz', 'lb', 'pc');
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "categories" (
    "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
    "enabled" boolean DEFAULT true NOT NULL,
    "store_id" uuid NOT NULL,
    "parent_category_id" uuid,
    "thumbnail_url" varchar(256)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "category_names" (
    "category_id" uuid NOT NULL,
    "language" varchar(2) NOT NULL,
    "name" varchar(128) NOT NULL,
    CONSTRAINT "category_names_category_id_language_name_pk" PRIMARY KEY("category_id","language","name")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "job_logs" (
    "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
    "tag" varchar(32),
    "started_at" timestamp DEFAULT '2024-05-19 08:10:24.256' NOT NULL,
    "is_successful" boolean DEFAULT false NOT NULL,
    "is_failed" boolean DEFAULT false NOT NULL,
    "is_running" boolean DEFAULT true NOT NULL,
    "completed_at" timestamp,
    "store_id" uuid,
    "raw_logs" text
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "media" (
    "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
    "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
    "product_id" uuid NOT NULL,
    "store_id" uuid NOT NULL,
    "type" "media_type" NOT NULL,
    "enabled" boolean DEFAULT true NOT NULL,
    "url" varchar(256) NOT NULL,
    "updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "prices" (
    "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
    "product_id" uuid NOT NULL,
    "store_id" uuid NOT NULL,
    "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
    "amount" integer NOT NULL,
    "currency" varchar(3) NOT NULL,
    "updated_at" timestamp (3),
    "discounted_amount" integer,
    "member_discounted_amount" integer,
    "is_in_stock" boolean DEFAULT true,
    "is_current_price" boolean DEFAULT true,
    "uom" "unit_of_measurement" DEFAULT 'pc',
    "campaign_start" timestamp,
    "campaign_end" timestamp
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "product_category" (
    "product_id" uuid NOT NULL,
    "category_id" uuid NOT NULL,
    CONSTRAINT "product_category_product_id_category_id_pk" PRIMARY KEY("product_id","category_id")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "product_names" (
    "product_id" uuid NOT NULL,
    "store_id" uuid NOT NULL,
    "language" varchar(2) NOT NULL,
    "sub_name" varchar(128) NOT NULL,
    CONSTRAINT "product_names_product_id_language_store_id_pk" PRIMARY KEY("product_id","language","store_id")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "products" (
    "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
    "enabled" boolean DEFAULT true NOT NULL,
    "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
    "ean" varchar(128) NOT NULL,
    "measurement" double precision DEFAULT 1 NOT NULL,
    "is_exact_measurement" boolean DEFAULT true NOT NULL,
    "uom" "unit_of_measurement" DEFAULT 'pc' NOT NULL,
    "updated_at" timestamp (3),
    CONSTRAINT "products_ean_unique" UNIQUE("ean")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "sessions" (
    "id" text PRIMARY KEY NOT NULL,
    "user_id" text NOT NULL,
    "expires_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "stores" (
    "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
    "enabled" boolean DEFAULT true NOT NULL,
    "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
    "name" varchar(128) NOT NULL,
    "updated_at" timestamp (3),
    "icon_url" varchar(256),
    "thumbnail_url" varchar(256),
    "website" varchar(256),
    "country_code" varchar(2) DEFAULT 'EE' NOT NULL,
    "currency" varchar(3) DEFAULT 'EUR' NOT NULL,
    "membership_name" varchar(64)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" (
    "id" text PRIMARY KEY NOT NULL,
    "role" "role" DEFAULT 'user' NOT NULL,
    "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
    "email" varchar(256) NOT NULL,
    "password" varchar(256) NOT NULL,
    "name" varchar(64),
    "updated_at" timestamp (3),
    CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "categories" ADD CONSTRAINT "categories_store_id_stores_id_fk" FOREIGN KEY ("store_id") REFERENCES "public"."stores"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "category_names" ADD CONSTRAINT "category_names_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "job_logs" ADD CONSTRAINT "job_logs_store_id_stores_id_fk" FOREIGN KEY ("store_id") REFERENCES "public"."stores"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "media" ADD CONSTRAINT "media_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "media" ADD CONSTRAINT "media_store_id_stores_id_fk" FOREIGN KEY ("store_id") REFERENCES "public"."stores"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "prices" ADD CONSTRAINT "prices_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "prices" ADD CONSTRAINT "prices_store_id_stores_id_fk" FOREIGN KEY ("store_id") REFERENCES "public"."stores"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "product_category" ADD CONSTRAINT "product_category_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "product_category" ADD CONSTRAINT "product_category_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "product_names" ADD CONSTRAINT "product_names_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "product_names" ADD CONSTRAINT "product_names_store_id_stores_id_fk" FOREIGN KEY ("store_id") REFERENCES "public"."stores"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
 ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
 WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "category_enabled_idx" ON "categories" ("enabled");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "category_store_idx" ON "categories" ("store_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "category_name_idx" ON "category_names" ("name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "category_name_lang_idx" ON "category_names" ("language");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "media_type_idx" ON "media" ("type");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "media_enabled_idx" ON "media" ("enabled");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "price_amount_idx" ON "prices" ("amount");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "price_discounted_amount_idx" ON "prices" ("discounted_amount");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "price_member_discounted_amount_idx" ON "prices" ("member_discounted_amount");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "price_is_in_stock_idx" ON "prices" ("is_in_stock");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "price_is_current_price_idx" ON "prices" ("is_current_price");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "price_currency_idx" ON "prices" ("currency");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "product_sub_name_idx" ON "product_names" ("sub_name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "product_sub_name_lang_idx" ON "product_names" ("language");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "product_sub_name_store_idx" ON "product_names" ("store_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "product_enabled_idx" ON "products" ("enabled");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "product_ean_idx" ON "products" ("ean");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "product_measurement_idx" ON "products" ("measurement");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "product_uom_idx" ON "products" ("uom");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "product_is_exact_measurement_idx" ON "products" ("is_exact_measurement");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "store_enabled_idx" ON "stores" ("enabled");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "store_name_idx" ON "stores" ("name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_email_idx" ON "users" ("email");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_role_idx" ON "users" ("role");

Thank you all in advance for your help!

Expected behavior

Drizzle Studio should load without problems and show the tables/relations after generating and running the migrations.

Environment & setup

package.json

{
  "name": "api",
  "version": "0.1.0",
  "module": "index.ts",
  "type": "module",
  "scripts": {
    "dev": "npx tsx watch ./index.ts",
    "test": "jest",
    "start": "npx tsx ./index.ts",
    "studio": "drizzle-kit studio",
    "generate": "drizzle-kit generate",
    "migrate": "npx tsx src/db/migrate.ts",
    "drizzle:up": "drizzle-kit up",
    "lint": "eslint --cache \"**/*.{js,jsx,ts,tsx}\"",
    "lint:fix": "eslint --cache --fix \"**/*.{js,jsx,ts,tsx}\"",
  },
  "devDependencies": {
    "@types/bun": "latest",
    "@types/express": "^4.17.21",
    "@types/jest": "^29.5.12",
    "@types/node": "^20.12.11",
    "@types/pg": "^8.11.6",
    "axios": "^1.6.8",
    "bun-types": "^1.1.7",
    "drizzle-kit": "^0.21.2",
    "eslint": "^9.2.0",
    "eslint-config-prettier": "^9.1.0",
    "eslint-plugin-prettier": "^5.1.3",
    "husky": "^9.0.11",
    "jest": "^29.7.0",
    "lint-staged": "^15.2.2",
    "nodemon": "^3.1.0",
    "prettier": "^3.2.5",
    "sass": "^1.76.0",
    "ts-jest": "^29.1.2",
    "ts-node": "^10.9.2",
    "tsx": "^4.10.1",
    "typescript": "^5.4.5"
  },
  "peerDependencies": {
    "typescript": "^5.0.0"
  },
  "dependencies": {
    "@lucia-auth/adapter-drizzle": "^1.0.7",
    "body-parser": "^1.20.2",
    "dotenv": "^16.4.5",
    "drizzle-orm": "^0.30.10",
    "express": "^4.19.2",
    "express-rate-limit": "^7.2.0",
    "glob": "^10.3.15",
    "helmet": "^7.1.0",
    "lucia": "^3.2.0",
    "oslo": "^1.2.0",
    "pg": "^8.11.5"
  },
  "jest": {
    "preset": "ts-jest",
    "moduleDirectories": [
      "node_modules",
      "<rootDir>"
    ],
    "transformIgnorePatterns": [
      "node_modules/(?!@lucia-auth/adapter-drizzle)"
    ],
    "setupFiles": [
      "<rootDir>/spec.setup.ts"
    ]
  }
}

I'm using VS Code Dev Containers as my development environment. Here are the config files:

devcontainer.json

{
  "name": "Python 3 & PostgreSQL & NodeJS & Typescript",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
  "features": {
    "ghcr.io/devcontainers/features/node:1": {
      "nodeGypDependencies": true,
      "version": "lts",
      "nvmVersion": "latest"
    },
    "ghcr.io/devcontainers-community/npm-features/typescript:1": {
      "version": "latest"
    }
  }
}

drizzle.config.ts

import 'dotenv/config';
import type { Config } from 'drizzle-kit';

export default {
  schema: './src/db/schema/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    host: process.env.DB_HOST ?? 'db',
    port: Number.parseInt(process.env.DB_PORT ?? '5432'),
    user: process.env.POSTGRES_USER ?? 'admin',
    password: process.env.POSTGRES_PASSWORD ?? 'admin',
    database: process.env.POSTGRES_DB ?? 'apidb',
  },
} satisfies Config;

docker-compose.yml

version: '3.8'

services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile

    volumes:
      - ../..:/workspaces:cached

    command: sleep infinity

    network_mode: service:db

  db:
    image: postgres:latest
    restart: unless-stopped
    volumes:
      - postgres-data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: admin
      POSTGRES_DB: apidb
      POSTGRES_PASSWORD: admin
      POSTGRES_ROOT_PASSWORD: admin

  admin:
    image: dpage/pgadmin4
    restart: unless-stopped
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@example.com
      PGADMIN_DEFAULT_PASSWORD: admin
    ports:
      - "8888:80"

volumes:
  postgres-data:

Dockerfile

FROM mcr.microsoft.com/devcontainers/python:1-3.11-bullseye

ENV PYTHONUNBUFFERED 1

# This part is necessary for Puppeteer to work
RUN apt-get update \
    && export DEBIAN_FRONTEND=noninteractive \
    && apt-get -y install --no-install-recommends \
    vim \
    libdbus-1-3 \
    curl \
    gnupg \
    wget \
    gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget libgbm-dev \
    && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/*
recoskyler commented 1 month ago

It is a bit embarrassing to solve the issue a few hours after I have created it, but here's the thing I did wrong just in case someone else is having the same problem:

I had to change the related tables in the categoryRelations and productRelations from products: many(products)/categories: many(categories) to products: many(productCategory)/categories: many(productCategory) like so:

schema.ts

Omitted unrelated parts of the file*

//* Products

export const products = pgTable(
  "products",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    enabled: boolean("enabled").notNull().default(true),
    createdAt: timestamp("created_at")
      .notNull()
      .default(sql`CURRENT_TIMESTAMP`),
    ean: varchar("ean", { length: 128 }).unique().notNull(),
    measurement: doublePrecision("measurement").notNull().default(1),
    isExactMeasurement: boolean("is_exact_measurement").notNull().default(true),
    uom: unitOfMeasurementEnum("uom").notNull().default("pc"),
    updatedAt: timestamp("updated_at", {
      mode: "date",
      precision: 3,
    }).$onUpdate(() => new Date()),
  },
  (table) => ({
    enabledIdx: index("product_enabled_idx").on(table.enabled),
    eanIdx: index("product_ean_idx").on(table.ean),
    measurementIdx: index("product_measurement_idx").on(table.measurement),
    uomIdx: index("product_uom_idx").on(table.uom),
    isExactMeasurementIdx: index("product_is_exact_measurement_idx").on(
      table.isExactMeasurement,
    ),
  }),
);

export const productRelations = relations(products, ({ many }) => ({
  categories: many(productCategory),  // CHANGED THIS
  subNames: many(productSubNames, {
    relationName: "product-sub-names",
  }),
  prices: many(prices, {
    relationName: "product-prices",
  }),
  media: many(media, {
    relationName: "product-media",
  }),
  names: many(productNames, {
    relationName: "product-names",
  }),
  stores: many(stores, {
    relationName: "store-products",
  }),
}));

//* Product-Category

export const productCategory = pgTable(
  "product_category",
  {
    productId: uuid("product_id")
      .notNull()
      .references(() => products.id),
    categoryId: uuid("category_id")
      .notNull()
      .references(() => categories.id),
  },
  (table) => ({
    pk: primaryKey({ columns: [table.productId, table.categoryId] }),
  }),
);

export const productCategoryRelations = relations(
  productCategory,
  ({ one }) => ({
    product: one(products, {
      fields: [productCategory.productId],
      references: [products.id],
    }),
    category: one(categories, {
      fields: [productCategory.categoryId],
      references: [categories.id],
    }),
  }),
);

//* Categories

export const categories = pgTable(
  "categories",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    enabled: boolean("enabled").notNull().default(true),
    storeId: uuid("store_id")
      .notNull()
      .references(() => stores.id),
    parentCategoryId: uuid("parent_category_id"),
    thumbnailUrl: varchar("thumbnail_url", { length: 256 }),
  },
  (table) => ({
    enabledIdx: index("category_enabled_idx").on(table.enabled),
    storeIdx: index("category_store_idx").on(table.storeId),
  }),
);

export const categoryRelations = relations(categories, ({ one, many }) => ({
  parent: one(categories, {
    fields: [categories.parentCategoryId],
    references: [categories.id],
    relationName: "sub-categories",
  }),
  subCategories: many(categories, {
    relationName: "sub-categories",
  }),
  names: many(categoryNames, {
    relationName: "category-names",
  }),
  products: many(productCategory), // CHANGED THIS
  store: one(stores, {
    fields: [categories.storeId],
    references: [stores.id],
    relationName: "store-categories",
  }),
}));

This would've been easily solved if I read the documentation on many-to-many relationships carefully...