spike(db): evaluate Drizzle and Tinqer against the hardest query we have (#216)
Both libraries converted the same target — `buildItemFilterSql`, six clauses composed at run time, a recursive CTE for the category subtree, an `ANY(...::int[])` tag match with a count equality. Nothing in `src/routes` or `src/itemFilters.ts` is touched; this branch only adds spike artifacts alongside them.
Drizzle cleared the blocker the issue named first. `backend/tsconfig.json` is `module: commonjs` and Drizzle is ESM-first, but it compiles under the existing config and requires at runtime, so no ESM migration is hiding inside this one.
`drizzle-kit pull` introspected all sixteen tables plus `pgmigrations`, 104 columns, 8 indexes and 20 foreign keys, and got the hard parts right: the self-referencing `categories.parent_id`, and both partial unique indexes with `lower(name)` and their `WHERE` predicates.
The converted filter produces byte-equivalent results. Five filter combinations run against the dev database return identical id lists to the current implementation, including the recursive subtree — 1805, 2145, 4, 1918 and 2145 rows respectively.
The injection question the issue asked about is answered yes, and it is stronger than expected. In a Drizzle `sql` template `${value}` emits a bind parameter, not text, so there is no way to spell "interpolate this as SQL" by accident. Feeding `"1); DROP TABLE items; --"` as a status produced it in the parameter array and nowhere in the query text. That is the #202 invariant enforced by the type system rather than by a comment and two tests.
Two Drizzle findings worth having before committing to 187 call sites. Arrays do not bind the way the raw driver does: `${array}` expands into a placeholder list, so `ANY(($1, $2)::int[])` type-checks, reads correctly, and fails at run time as invalid Postgres. `sql.param()` is required, and nothing warns. And the first generated migration after a pull carried spurious drops and recreations of the three expression indexes; re-running with no schema change reports nothing to migrate, so it settles rather than recurring, but that first migration would need hand-editing.
Tinqer is genuinely LINQ-to-SQL — it parses the lambda with OXC at run time and compiles a real expression tree — and it cannot express this query. Compound conditions and array membership work. A ternary fails. A block body with an `if` fails. Those are the only two ways to make a clause optional inside the lambda, and there is no raw-SQL escape hatch in its API, so six independent optional clauses would mean 64 hand-written plans or neutral sentinels that do not exist for the category and tag clauses.
Its failure mode compounds that: `defineSelect` parses eagerly and throws, so an unsupported query type-checks cleanly and crashes when the module is first required. `src/db-tinqer/probe.ts` wraps every case in a function for that reason.
It is also `0.0.27` with 24 stars, and its Postgres support is a `pg-promise` adapter rather than the `pg` driver already in use.
I was wrong earlier to say LINQ-to-SQL is impossible in TypeScript because it needs C# expression trees. Tinqer reconstructs the tree by parsing the lambda source. The claim should have been that it is possible and rare, and the constraint is what the parser accepts.
Verified: backend build clean, 280 unit tests and 255 integration tests pass, unchanged by this branch.
Refs #216
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
import { pgTable, serial, varchar, timestamp, text, foreignKey, integer, boolean, jsonb, index, uniqueIndex, unique, primaryKey } from "drizzle-orm/pg-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
|
||||
|
||||
export const pgmigrations = pgTable("pgmigrations", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
runOn: timestamp("run_on", { mode: 'string' }).notNull(),
|
||||
});
|
||||
|
||||
export const adminSettings = pgTable("admin_settings", {
|
||||
key: text().primaryKey().notNull(),
|
||||
value: text().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const itemImages = pgTable("item_images", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
itemId: integer("item_id").notNull(),
|
||||
imagePath: text("image_path").notNull(),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "item_images_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const shippingAddresses = pgTable("shipping_addresses", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
customerId: integer("customer_id").notNull(),
|
||||
fullName: text("full_name").notNull(),
|
||||
addressLine1: text("address_line1").notNull(),
|
||||
addressLine2: text("address_line2"),
|
||||
city: text().notNull(),
|
||||
state: text().notNull(),
|
||||
postalCode: text("postal_code").notNull(),
|
||||
country: text().default('US').notNull(),
|
||||
isDefault: boolean("is_default").default(false).notNull(),
|
||||
uspsValidated: boolean("usps_validated").default(false).notNull(),
|
||||
uspsStandardized: jsonb("usps_standardized"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "shipping_addresses_customer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const items = pgTable("items", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
priceCents: integer("price_cents").notNull(),
|
||||
status: text().default('pending').notNull(),
|
||||
reservedUntil: timestamp("reserved_until", { withTimezone: true, mode: 'string' }),
|
||||
soldAt: timestamp("sold_at", { withTimezone: true, mode: 'string' }),
|
||||
paypalOrderId: text("paypal_order_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
categoryId: integer("category_id"),
|
||||
}, (table) => [
|
||||
index("items_category_id_idx").using("btree", table.categoryId.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.categoryId],
|
||||
foreignColumns: [categories.id],
|
||||
name: "items_category_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
]);
|
||||
|
||||
export const tags = pgTable("tags", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
color: text().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex("tags_name_uniq").using("btree", sql`lower(name)`),
|
||||
]);
|
||||
|
||||
export const categories = pgTable("categories", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
parentId: integer("parent_id"),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex("categories_child_name_uniq").using("btree", sql`parent_id`, sql`lower(name)`).where(sql`(parent_id IS NOT NULL)`),
|
||||
index("categories_parent_id_idx").using("btree", table.parentId.asc().nullsLast().op("int4_ops")),
|
||||
uniqueIndex("categories_root_name_uniq").using("btree", sql`lower(name)`).where(sql`(parent_id IS NULL)`),
|
||||
foreignKey({
|
||||
columns: [table.parentId],
|
||||
foreignColumns: [table.id],
|
||||
name: "categories_parent_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const orders = pgTable("orders", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
itemId: integer("item_id"),
|
||||
customerId: integer("customer_id"),
|
||||
checkoutId: integer("checkout_id"),
|
||||
processor: text().notNull(),
|
||||
processorOrderId: text("processor_order_id"),
|
||||
amountCents: integer("amount_cents"),
|
||||
status: text(),
|
||||
rawEvent: jsonb("raw_event"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "orders_item_id_fkey"
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "orders_customer_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
foreignKey({
|
||||
columns: [table.checkoutId],
|
||||
foreignColumns: [checkouts.id],
|
||||
name: "orders_checkout_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
]);
|
||||
|
||||
export const cartItems = pgTable("cart_items", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
cartId: integer("cart_id").notNull(),
|
||||
itemId: integer("item_id").notNull(),
|
||||
addedAt: timestamp("added_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
lastReminderSentAt: timestamp("last_reminder_sent_at", { withTimezone: true, mode: 'string' }),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.cartId],
|
||||
foreignColumns: [carts.id],
|
||||
name: "cart_items_cart_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "cart_items_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
unique("cart_items_item_id_key").on(table.itemId),
|
||||
]);
|
||||
|
||||
export const carts = pgTable("carts", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
customerId: integer("customer_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "carts_customer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
unique("carts_customer_id_key").on(table.customerId),
|
||||
]);
|
||||
|
||||
export const customerTokens = pgTable("customer_tokens", {
|
||||
token: text().primaryKey().notNull(),
|
||||
customerId: integer("customer_id").notNull(),
|
||||
kind: text().notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "customer_tokens_customer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const checkouts = pgTable("checkouts", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
customerId: integer("customer_id"),
|
||||
shippingAddressId: integer("shipping_address_id"),
|
||||
processor: text().notNull(),
|
||||
processorOrderId: text("processor_order_id"),
|
||||
amountCents: integer("amount_cents"),
|
||||
status: text().default('pending').notNull(),
|
||||
rawEvent: jsonb("raw_event"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "checkouts_customer_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
foreignKey({
|
||||
columns: [table.shippingAddressId],
|
||||
foreignColumns: [shippingAddresses.id],
|
||||
name: "checkouts_shipping_address_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
]);
|
||||
|
||||
export const customerSessions = pgTable("customer_sessions", {
|
||||
token: text().primaryKey().notNull(),
|
||||
customerId: integer("customer_id").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "customer_sessions_customer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const customers = pgTable("customers", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
email: text().notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
marketingConsent: boolean("marketing_consent").default(false).notNull(),
|
||||
marketingConsentAt: timestamp("marketing_consent_at", { withTimezone: true, mode: 'string' }),
|
||||
marketingConsentText: text("marketing_consent_text"),
|
||||
unsubscribeToken: text("unsubscribe_token").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
disabledAt: timestamp("disabled_at", { withTimezone: true, mode: 'string' }),
|
||||
favoriteAlerts: boolean("favorite_alerts").default(false).notNull(),
|
||||
favoriteAlertsAt: timestamp("favorite_alerts_at", { withTimezone: true, mode: 'string' }),
|
||||
favoriteAlertsText: text("favorite_alerts_text"),
|
||||
firstName: text("first_name"),
|
||||
lastName: text("last_name"),
|
||||
}, (table) => [
|
||||
index("customers_disabled_at_idx").using("btree", table.disabledAt.asc().nullsLast().op("timestamptz_ops")).where(sql`(disabled_at IS NOT NULL)`),
|
||||
unique("customers_email_key").on(table.email),
|
||||
unique("customers_unsubscribe_token_key").on(table.unsubscribeToken),
|
||||
]);
|
||||
|
||||
export const itemTags = pgTable("item_tags", {
|
||||
itemId: integer("item_id").notNull(),
|
||||
tagId: integer("tag_id").notNull(),
|
||||
}, (table) => [
|
||||
index("item_tags_tag_id_idx").using("btree", table.tagId.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "item_tags_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [table.tagId],
|
||||
foreignColumns: [tags.id],
|
||||
name: "item_tags_tag_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
primaryKey({ columns: [table.itemId, table.tagId], name: "item_tags_pkey"}),
|
||||
]);
|
||||
|
||||
export const favorites = pgTable("favorites", {
|
||||
customerId: integer("customer_id").notNull(),
|
||||
itemId: integer("item_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
index("favorites_item_id_idx").using("btree", table.itemId.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "favorites_customer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "favorites_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
primaryKey({ columns: [table.customerId, table.itemId], name: "favorites_pkey"}),
|
||||
]);
|
||||
|
||||
export const checkoutItems = pgTable("checkout_items", {
|
||||
checkoutId: integer("checkout_id").notNull(),
|
||||
itemId: integer("item_id").notNull(),
|
||||
priceCents: integer("price_cents").notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.checkoutId],
|
||||
foreignColumns: [checkouts.id],
|
||||
name: "checkout_items_checkout_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "checkout_items_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
primaryKey({ columns: [table.checkoutId, table.itemId], name: "checkout_items_pkey"}),
|
||||
]);
|
||||
Reference in New Issue
Block a user