Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "neon-vercel-postgres",
|
||||||
|
"description": "Set up serverless Postgres with Neon or Vercel Postgres for Cloudflare Workers/Edge. Includes connection pooling, git-like branching for preview environments, and Drizzle/Prisma integration. Use when: setting up edge Postgres, configuring database branching, or troubleshooting TCP not supported, connection pool exhausted, SSL config (sslmode=require), or Prisma edge compatibility.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Jeremy Dawes",
|
||||||
|
"email": "jeremy@jezweb.net"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# neon-vercel-postgres
|
||||||
|
|
||||||
|
Set up serverless Postgres with Neon or Vercel Postgres for Cloudflare Workers/Edge. Includes connection pooling, git-like branching for preview environments, and Drizzle/Prisma integration. Use when: setting up edge Postgres, configuring database branching, or troubleshooting TCP not supported, connection pool exhausted, SSL config (sslmode=require), or Prisma edge compatibility.
|
||||||
178
assets/drizzle-schema.ts
Normal file
178
assets/drizzle-schema.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Complete Drizzle Schema Template for Neon/Vercel Postgres
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. Copy this file to your project: cp assets/drizzle-schema.ts db/schema.ts
|
||||||
|
* 2. Customize tables to match your app's data model
|
||||||
|
* 3. Generate migrations: npx drizzle-kit generate
|
||||||
|
* 4. Apply migrations: npx drizzle-kit migrate
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pgTable, serial, text, timestamp, integer, boolean, jsonb, index, unique } from 'drizzle-orm/pg-core';
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USERS TABLE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
avatar: text('avatar'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
// Indexes for common queries
|
||||||
|
emailIdx: index('users_email_idx').on(table.email),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// POSTS TABLE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const posts = pgTable('posts', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
content: text('content'),
|
||||||
|
published: boolean('published').default(false).notNull(),
|
||||||
|
slug: text('slug').notNull().unique(),
|
||||||
|
metadata: jsonb('metadata').$type<{
|
||||||
|
views?: number;
|
||||||
|
likes?: number;
|
||||||
|
tags?: string[];
|
||||||
|
}>(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
// Indexes for common queries
|
||||||
|
userIdIdx: index('posts_user_id_idx').on(table.userId),
|
||||||
|
slugIdx: index('posts_slug_idx').on(table.slug),
|
||||||
|
publishedIdx: index('posts_published_idx').on(table.published),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMMENTS TABLE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const comments = pgTable('comments', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
postId: integer('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
// Indexes for common queries
|
||||||
|
postIdIdx: index('comments_post_id_idx').on(table.postId),
|
||||||
|
userIdIdx: index('comments_user_id_idx').on(table.userId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RELATIONS (for Drizzle query API)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
|
posts: many(posts),
|
||||||
|
comments: many(comments),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const postsRelations = relations(posts, ({ one, many }) => ({
|
||||||
|
author: one(users, {
|
||||||
|
fields: [posts.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
comments: many(comments),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const commentsRelations = relations(comments, ({ one }) => ({
|
||||||
|
post: one(posts, {
|
||||||
|
fields: [comments.postId],
|
||||||
|
references: [posts.id],
|
||||||
|
}),
|
||||||
|
author: one(users, {
|
||||||
|
fields: [comments.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPE EXPORTS (for TypeScript)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type NewUser = typeof users.$inferInsert;
|
||||||
|
|
||||||
|
export type Post = typeof posts.$inferSelect;
|
||||||
|
export type NewPost = typeof posts.$inferInsert;
|
||||||
|
|
||||||
|
export type Comment = typeof comments.$inferSelect;
|
||||||
|
export type NewComment = typeof comments.$inferInsert;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USAGE EXAMPLE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* db/index.ts:
|
||||||
|
*
|
||||||
|
* import { drizzle } from 'drizzle-orm/neon-http';
|
||||||
|
* import { neon } from '@neondatabase/serverless';
|
||||||
|
* import * as schema from './schema';
|
||||||
|
*
|
||||||
|
* const sql = neon(process.env.DATABASE_URL!);
|
||||||
|
* export const db = drizzle(sql, { schema });
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* drizzle.config.ts:
|
||||||
|
*
|
||||||
|
* import { defineConfig } from 'drizzle-kit';
|
||||||
|
*
|
||||||
|
* export default defineConfig({
|
||||||
|
* schema: './db/schema.ts',
|
||||||
|
* out: './db/migrations',
|
||||||
|
* dialect: 'postgresql',
|
||||||
|
* dbCredentials: {
|
||||||
|
* url: process.env.DATABASE_URL!
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query Examples:
|
||||||
|
*
|
||||||
|
* // SELECT with joins
|
||||||
|
* const postsWithAuthors = await db.query.posts.findMany({
|
||||||
|
* with: {
|
||||||
|
* author: true,
|
||||||
|
* comments: {
|
||||||
|
* with: {
|
||||||
|
* author: true
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // INSERT
|
||||||
|
* const newUser = await db.insert(users).values({
|
||||||
|
* email: 'alice@example.com',
|
||||||
|
* name: 'Alice'
|
||||||
|
* }).returning();
|
||||||
|
*
|
||||||
|
* // UPDATE
|
||||||
|
* await db.update(posts).set({
|
||||||
|
* published: true,
|
||||||
|
* updatedAt: new Date()
|
||||||
|
* }).where(eq(posts.id, postId));
|
||||||
|
*
|
||||||
|
* // DELETE
|
||||||
|
* await db.delete(comments).where(eq(comments.id, commentId));
|
||||||
|
*
|
||||||
|
* // Transaction
|
||||||
|
* await db.transaction(async (tx) => {
|
||||||
|
* const [user] = await tx.insert(users).values({ email, name }).returning();
|
||||||
|
* await tx.insert(posts).values({ userId: user.id, title, content });
|
||||||
|
* });
|
||||||
|
*/
|
||||||
14
assets/example-template.txt
Normal file
14
assets/example-template.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[TODO: Example Template File]
|
||||||
|
|
||||||
|
[TODO: This directory contains files that will be used in the OUTPUT that Claude produces.]
|
||||||
|
|
||||||
|
[TODO: Examples:]
|
||||||
|
- Templates (.html, .tsx, .md)
|
||||||
|
- Images (.png, .svg)
|
||||||
|
- Fonts (.ttf, .woff)
|
||||||
|
- Boilerplate code
|
||||||
|
- Configuration file templates
|
||||||
|
|
||||||
|
[TODO: Delete this file and add your actual assets]
|
||||||
|
|
||||||
|
These files are NOT loaded into context. They are copied or used directly in the final output.
|
||||||
85
plugin.lock.json
Normal file
85
plugin.lock.json
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:jezweb/claude-skills:skills/neon-vercel-postgres",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "5b780ac7f23b3e1ba36341d8ac20256b6d0bbf0e",
|
||||||
|
"treeHash": "640d68f99a0685daa86b95feb7932679608848ba8da7b970a0a14c43303fdc82",
|
||||||
|
"generatedAt": "2025-11-28T10:19:04.175308Z",
|
||||||
|
"toolVersion": "publish_plugins.py@0.2.0"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||||
|
"branch": "master",
|
||||||
|
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||||
|
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"name": "neon-vercel-postgres",
|
||||||
|
"description": "Set up serverless Postgres with Neon or Vercel Postgres for Cloudflare Workers/Edge. Includes connection pooling, git-like branching for preview environments, and Drizzle/Prisma integration. Use when: setting up edge Postgres, configuring database branching, or troubleshooting TCP not supported, connection pool exhausted, SSL config (sslmode=require), or Prisma edge compatibility.",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "7d9bbd828e6f783bf294f0ccc126137c11e28439925e2646943068bcb6e213ac"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"sha256": "1fee2426aa76e08c167c8a195e9be71634caa1c9e604e27b0db1b0f11c8f19f4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "references/example-reference.md",
|
||||||
|
"sha256": "77c788d727d05d6479a61d6652b132e43882ffc67c145bb46ba880567d83f7f8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/test-connection.ts",
|
||||||
|
"sha256": "061d43c15435ba8a5331a130f73098cc725dbec06ebf2fa76f4c8a42944c005e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/example-script.sh",
|
||||||
|
"sha256": "83d2b09d044811608e17cbd8e66d993b1e9998c7bd3379a42ab81fbdba973e0e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "a85e08a3e1218cfcbb2722c505c73c3974cc9bdf7b73444c2ac447f0c5ed03d3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/neon-basic-queries.ts",
|
||||||
|
"sha256": "3f7dc86f72f6896cef17962e8aa6f744bed4a63a0ac44ecbe013b9c58e21e4cf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/package.json",
|
||||||
|
"sha256": "2346887ba4f565068604be45ed5e3397f96954a471780ad455a253163fbd70ff"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/drizzle-schema.ts",
|
||||||
|
"sha256": "6a1c117b10c64f42ecde38276c3080f04a3ad66cc19f32b47c8839f4fb4953d2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/drizzle-queries.ts",
|
||||||
|
"sha256": "1fc0f42cbff2d6c2cae50812ecfc4573f811d95414f97556a90712894f751e69"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/drizzle-migrations-workflow.md",
|
||||||
|
"sha256": "e5d228e56128613c6473cd8d0fd2824d62b3c6122032528451498a0ab768e387"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "assets/example-template.txt",
|
||||||
|
"sha256": "3f725c80d70847fd8272bf1400515ba753f12f98f3b294d09e50b54b4c1b024a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "assets/drizzle-schema.ts",
|
||||||
|
"sha256": "6a1c117b10c64f42ecde38276c3080f04a3ad66cc19f32b47c8839f4fb4953d2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "640d68f99a0685daa86b95feb7932679608848ba8da7b970a0a14c43303fdc82"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
26
references/example-reference.md
Normal file
26
references/example-reference.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# [TODO: Reference Document Name]
|
||||||
|
|
||||||
|
[TODO: This file contains reference documentation that Claude can load when needed.]
|
||||||
|
|
||||||
|
[TODO: Delete this file if you don't have reference documentation to provide.]
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
[TODO: Explain what information this document contains]
|
||||||
|
|
||||||
|
## When Claude Should Use This
|
||||||
|
|
||||||
|
[TODO: Describe specific scenarios where Claude should load this reference]
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
[TODO: Add your reference content here - schemas, guides, specifications, etc.]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: This file is NOT loaded into context by default. Claude will only load it when:
|
||||||
|
- It determines the information is needed
|
||||||
|
- You explicitly ask Claude to reference it
|
||||||
|
- The SKILL.md instructions direct Claude to read it
|
||||||
|
|
||||||
|
Keep this file under 10k words for best performance.
|
||||||
15
scripts/example-script.sh
Executable file
15
scripts/example-script.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# [TODO: Script Name]
|
||||||
|
# [TODO: Brief description of what this script does]
|
||||||
|
|
||||||
|
# Example script structure - delete if not needed
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
# [TODO: Add your script logic here]
|
||||||
|
|
||||||
|
echo "Example script - replace or delete this file"
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/example-script.sh [args]
|
||||||
102
scripts/test-connection.ts
Normal file
102
scripts/test-connection.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Test Neon/Vercel Postgres Connection
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm install -g tsx
|
||||||
|
* npx tsx scripts/test-connection.ts
|
||||||
|
*
|
||||||
|
* Environment Variables:
|
||||||
|
* DATABASE_URL or POSTGRES_URL - Your Neon/Vercel Postgres connection string
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { neon } from '@neondatabase/serverless';
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
const connectionString = process.env.DATABASE_URL || process.env.POSTGRES_URL;
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
console.error('❌ Error: DATABASE_URL or POSTGRES_URL environment variable not set');
|
||||||
|
console.log('\nSet your connection string:');
|
||||||
|
console.log(' export DATABASE_URL="postgresql://user:pass@ep-xyz-pooler.region.aws.neon.tech/db?sslmode=require"');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔗 Testing Neon/Vercel Postgres connection...\n');
|
||||||
|
|
||||||
|
// Check if using pooled connection
|
||||||
|
if (connectionString.includes('-pooler.')) {
|
||||||
|
console.log('✅ Using pooled connection string (recommended for serverless)');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Warning: Not using pooled connection string');
|
||||||
|
console.log(' For serverless, use pooled connection: ...@ep-xyz-pooler.region.aws.neon.tech/...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check SSL mode
|
||||||
|
if (connectionString.includes('sslmode=require')) {
|
||||||
|
console.log('✅ SSL mode enabled (sslmode=require)');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Warning: SSL mode not set. Add "?sslmode=require" to connection string');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sql = neon(connectionString);
|
||||||
|
|
||||||
|
// Test query
|
||||||
|
console.log('📊 Running test query: SELECT NOW()...');
|
||||||
|
const result = await sql`SELECT NOW() as current_time, version() as pg_version`;
|
||||||
|
|
||||||
|
console.log('✅ Connection successful!');
|
||||||
|
console.log('');
|
||||||
|
console.log('Database Info:');
|
||||||
|
console.log(` Time: ${result[0].current_time}`);
|
||||||
|
console.log(` Version: ${result[0].pg_version}`);
|
||||||
|
|
||||||
|
// Test table creation
|
||||||
|
console.log('');
|
||||||
|
console.log('🔧 Testing table operations...');
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS connection_test (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
test_message TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
console.log('✅ CREATE TABLE successful');
|
||||||
|
|
||||||
|
const [insertResult] = await sql`
|
||||||
|
INSERT INTO connection_test (test_message)
|
||||||
|
VALUES ('Connection test successful')
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
console.log('✅ INSERT successful');
|
||||||
|
console.log(` Inserted ID: ${insertResult.id}`);
|
||||||
|
|
||||||
|
const selectResult = await sql`SELECT * FROM connection_test ORDER BY created_at DESC LIMIT 1`;
|
||||||
|
console.log('✅ SELECT successful');
|
||||||
|
console.log(` Latest message: ${selectResult[0].test_message}`);
|
||||||
|
|
||||||
|
await sql`DROP TABLE connection_test`;
|
||||||
|
console.log('✅ DROP TABLE successful');
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('🎉 All tests passed! Your Neon/Vercel Postgres connection is working correctly.');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Connection failed:');
|
||||||
|
console.error(` ${error.message}`);
|
||||||
|
console.error('');
|
||||||
|
console.error('Common fixes:');
|
||||||
|
console.error(' 1. Verify connection string format');
|
||||||
|
console.error(' 2. Ensure using pooled connection (-pooler. in hostname)');
|
||||||
|
console.error(' 3. Add ?sslmode=require to connection string');
|
||||||
|
console.error(' 4. Check Neon dashboard for compute status');
|
||||||
|
console.error(' 5. Verify database exists and credentials are correct');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testConnection();
|
||||||
461
templates/drizzle-migrations-workflow.md
Normal file
461
templates/drizzle-migrations-workflow.md
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
# Drizzle Migrations Workflow for Neon Postgres
|
||||||
|
|
||||||
|
This guide shows the complete workflow for managing database migrations with Drizzle ORM and Neon Postgres.
|
||||||
|
|
||||||
|
## Initial Setup
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install drizzle-orm @neondatabase/serverless
|
||||||
|
npm install -D drizzle-kit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Configuration File
|
||||||
|
|
||||||
|
Create `drizzle.config.ts` in your project root:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './db/schema.ts',
|
||||||
|
out: './db/migrations',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add Scripts to package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:push": "drizzle-kit push"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema Definition
|
||||||
|
|
||||||
|
Create your schema in `db/schema.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { pgTable, serial, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
avatarUrl: text('avatar_url'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const posts = pgTable('posts', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
content: text('content'),
|
||||||
|
published: boolean('published').default(false),
|
||||||
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const comments = pgTable('comments', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
postId: integer('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Workflow
|
||||||
|
|
||||||
|
### Step 1: Generate Migration
|
||||||
|
|
||||||
|
After creating or modifying your schema, generate a migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a new migration file in `db/migrations/` directory:
|
||||||
|
- `db/migrations/0000_initial.sql` - First migration
|
||||||
|
- `db/migrations/0001_add_published_column.sql` - Second migration
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- Drizzle compares your schema with the database
|
||||||
|
- Generates SQL migration file with CREATE/ALTER/DROP statements
|
||||||
|
- Creates snapshot in `db/migrations/meta/` for future comparisons
|
||||||
|
|
||||||
|
### Step 2: Review Migration
|
||||||
|
|
||||||
|
**ALWAYS review generated SQL before applying!**
|
||||||
|
|
||||||
|
Example generated migration (`db/migrations/0000_initial.sql`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS "users" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text NOT NULL UNIQUE,
|
||||||
|
"avatar_url" text,
|
||||||
|
"created_at" timestamp DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "posts" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"content" text,
|
||||||
|
"published" boolean DEFAULT false,
|
||||||
|
"created_at" timestamp DEFAULT NOW(),
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "comments" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"post_id" integer NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT NOW(),
|
||||||
|
FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Apply Migration
|
||||||
|
|
||||||
|
Apply the migration to your database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- Drizzle connects to your Neon database
|
||||||
|
- Runs all pending migrations in order
|
||||||
|
- Records applied migrations in `__drizzle_migrations` table
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```
|
||||||
|
✅ Applying migration: 0000_initial.sql
|
||||||
|
✅ Migration applied successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Verify Migration
|
||||||
|
|
||||||
|
Use Drizzle Studio to verify your schema:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:studio
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens a web UI at `https://local.drizzle.studio` where you can:
|
||||||
|
- View tables and relationships
|
||||||
|
- Run queries
|
||||||
|
- Edit data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Add New Column
|
||||||
|
|
||||||
|
1. **Update schema** (`db/schema.ts`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
avatarUrl: text('avatar_url'),
|
||||||
|
bio: text('bio'), // ← New column
|
||||||
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Generate migration**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated SQL (`db/migrations/0001_add_bio.sql`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE "users" ADD COLUMN "bio" text;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Apply migration**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: Add Index
|
||||||
|
|
||||||
|
1. **Update schema** with index:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const posts = pgTable('posts', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
content: text('content'),
|
||||||
|
published: boolean('published').default(false),
|
||||||
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
|
}, (table) => ({
|
||||||
|
userIdIdx: index('user_id_idx').on(table.userId),
|
||||||
|
createdAtIdx: index('created_at_idx').on(table.createdAt),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Generate and apply**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: Rename Column
|
||||||
|
|
||||||
|
**⚠️ WARNING**: Drizzle treats renames as DROP + ADD (data loss!)
|
||||||
|
|
||||||
|
**Safe approach:**
|
||||||
|
|
||||||
|
1. Add new column
|
||||||
|
2. Migrate data (manually)
|
||||||
|
3. Drop old column
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 1: Add new column
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
name: text('name').notNull(), // Old
|
||||||
|
fullName: text('full_name').notNull(), // New
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Step 2: Migrate data manually
|
||||||
|
UPDATE users SET full_name = name WHERE full_name IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 3: Remove old column
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
fullName: text('full_name').notNull(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 4: Production Deployment
|
||||||
|
|
||||||
|
For production, use a separate migration script:
|
||||||
|
|
||||||
|
Create `scripts/migrate.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { drizzle } from 'drizzle-orm/neon-http';
|
||||||
|
import { neon } from '@neondatabase/serverless';
|
||||||
|
import { migrate } from 'drizzle-orm/neon-http/migrator';
|
||||||
|
|
||||||
|
async function runMigrations() {
|
||||||
|
const sql = neon(process.env.DATABASE_URL!);
|
||||||
|
const db = drizzle(sql);
|
||||||
|
|
||||||
|
console.log('Running migrations...');
|
||||||
|
await migrate(db, { migrationsFolder: './db/migrations' });
|
||||||
|
console.log('Migrations completed!');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runMigrations().catch((err) => {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"db:migrate:prod": "tsx scripts/migrate.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run in CI/CD:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Before deployment
|
||||||
|
npm run db:migrate:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative: Push (Schema Sync)
|
||||||
|
|
||||||
|
For **development only**, you can use `db:push` to sync schema without migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Compares schema with database
|
||||||
|
- Applies changes directly
|
||||||
|
- **No migration files created**
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- ✅ Local development (rapid iteration)
|
||||||
|
- ✅ Prototyping
|
||||||
|
|
||||||
|
**When NOT to use:**
|
||||||
|
- ❌ Production
|
||||||
|
- ❌ Shared databases
|
||||||
|
- ❌ Need migration history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Always Review Generated SQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate migration
|
||||||
|
npm run db:generate
|
||||||
|
|
||||||
|
# Review file before applying
|
||||||
|
cat db/migrations/0001_*.sql
|
||||||
|
|
||||||
|
# Apply only if safe
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Migrations on Development Database First
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create Neon branch for testing
|
||||||
|
neonctl branches create --name test-migration --parent main
|
||||||
|
|
||||||
|
# Get branch connection string
|
||||||
|
export DATABASE_URL=$(neonctl connection-string test-migration)
|
||||||
|
|
||||||
|
# Test migration
|
||||||
|
npm run db:migrate
|
||||||
|
|
||||||
|
# If successful, apply to main
|
||||||
|
neonctl branches delete test-migration
|
||||||
|
export DATABASE_URL=$(neonctl connection-string main)
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Commit Migration Files to Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add db/migrations/
|
||||||
|
git add db/schema.ts
|
||||||
|
git commit -m "feat: add bio column to users table"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Transactions for Multi-Step Migrations
|
||||||
|
|
||||||
|
Drizzle migrations run in transactions by default. If any step fails, all changes roll back.
|
||||||
|
|
||||||
|
### 5. Handle Data Migrations Separately
|
||||||
|
|
||||||
|
For complex data transformations, create custom migration scripts:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// db/migrations/custom/0001_migrate_user_data.ts
|
||||||
|
import { neon } from '@neondatabase/serverless';
|
||||||
|
|
||||||
|
const sql = neon(process.env.DATABASE_URL!);
|
||||||
|
|
||||||
|
// Custom data migration
|
||||||
|
await sql`UPDATE users SET status = 'active' WHERE status IS NULL`;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Problem: "No schema changes detected"
|
||||||
|
|
||||||
|
**Solution**: Drizzle uses snapshots. If out of sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Delete snapshot
|
||||||
|
rm -rf db/migrations/meta
|
||||||
|
|
||||||
|
# Regenerate
|
||||||
|
npm run db:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: "Relation already exists"
|
||||||
|
|
||||||
|
**Solution**: Migration already applied. Check `__drizzle_migrations` table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM __drizzle_migrations;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: "Cannot drop column with data"
|
||||||
|
|
||||||
|
**Solution**: Set column nullable first, then drop:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE users ALTER COLUMN old_column DROP NOT NULL;
|
||||||
|
ALTER TABLE users DROP COLUMN old_column;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Checklist
|
||||||
|
|
||||||
|
- [ ] `drizzle.config.ts` configured
|
||||||
|
- [ ] Schema defined in `db/schema.ts`
|
||||||
|
- [ ] Scripts added to `package.json`
|
||||||
|
- [ ] `DATABASE_URL` environment variable set
|
||||||
|
- [ ] Initial migration generated (`npm run db:generate`)
|
||||||
|
- [ ] Migration reviewed
|
||||||
|
- [ ] Migration applied (`npm run db:migrate`)
|
||||||
|
- [ ] Schema verified in Drizzle Studio
|
||||||
|
- [ ] Migration files committed to git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **Drizzle ORM Docs**: https://orm.drizzle.team/docs/overview
|
||||||
|
- **Drizzle Kit Docs**: https://orm.drizzle.team/docs/kit-overview
|
||||||
|
- **Neon + Drizzle Guide**: https://orm.drizzle.team/docs/quick-postgresql/neon
|
||||||
|
- **Migration Best Practices**: https://orm.drizzle.team/docs/migrations
|
||||||
543
templates/drizzle-queries.ts
Normal file
543
templates/drizzle-queries.ts
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
// Drizzle ORM Query Patterns for Neon Postgres
|
||||||
|
// Type-safe queries with full TypeScript support
|
||||||
|
|
||||||
|
import { db } from './db'; // Assuming you have db/index.ts set up
|
||||||
|
import { users, posts, comments } from './db/schema'; // Assuming you have db/schema.ts
|
||||||
|
import { eq, and, or, gt, lt, gte, lte, like, inArray, isNull, isNotNull, desc, asc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SELECT QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Simple select all
|
||||||
|
export async function getAllUsers() {
|
||||||
|
const allUsers = await db.select().from(users);
|
||||||
|
return allUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select specific columns
|
||||||
|
export async function getUserEmails() {
|
||||||
|
const emails = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
email: users.email
|
||||||
|
})
|
||||||
|
.from(users);
|
||||||
|
return emails;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select with WHERE clause
|
||||||
|
export async function getUserById(id: number) {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, id));
|
||||||
|
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select with multiple conditions (AND)
|
||||||
|
export async function getActiveUserByEmail(email: string) {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.email, email),
|
||||||
|
eq(users.active, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select with OR conditions
|
||||||
|
export async function searchUsers(searchTerm: string) {
|
||||||
|
const results = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
like(users.name, `%${searchTerm}%`),
|
||||||
|
like(users.email, `%${searchTerm}%`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select with comparison operators
|
||||||
|
export async function getRecentPosts(daysAgo: number) {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - daysAgo);
|
||||||
|
|
||||||
|
const recentPosts = await db
|
||||||
|
.select()
|
||||||
|
.from(posts)
|
||||||
|
.where(gte(posts.createdAt, cutoffDate))
|
||||||
|
.orderBy(desc(posts.createdAt));
|
||||||
|
|
||||||
|
return recentPosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select with IN clause
|
||||||
|
export async function getUsersByIds(ids: number[]) {
|
||||||
|
const selectedUsers = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, ids));
|
||||||
|
|
||||||
|
return selectedUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select with NULL checks
|
||||||
|
export async function getUsersWithoutAvatar() {
|
||||||
|
const usersNoAvatar = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(isNull(users.avatarUrl));
|
||||||
|
|
||||||
|
return usersNoAvatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JOINS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Inner join
|
||||||
|
export async function getPostsWithAuthors() {
|
||||||
|
const postsWithAuthors = await db
|
||||||
|
.select({
|
||||||
|
postId: posts.id,
|
||||||
|
postTitle: posts.title,
|
||||||
|
postContent: posts.content,
|
||||||
|
authorId: users.id,
|
||||||
|
authorName: users.name,
|
||||||
|
authorEmail: users.email
|
||||||
|
})
|
||||||
|
.from(posts)
|
||||||
|
.innerJoin(users, eq(posts.userId, users.id))
|
||||||
|
.orderBy(desc(posts.createdAt));
|
||||||
|
|
||||||
|
return postsWithAuthors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left join (get all posts, even without authors)
|
||||||
|
export async function getAllPostsWithOptionalAuthors() {
|
||||||
|
const allPosts = await db
|
||||||
|
.select({
|
||||||
|
postId: posts.id,
|
||||||
|
postTitle: posts.title,
|
||||||
|
authorName: users.name // Will be null if no author
|
||||||
|
})
|
||||||
|
.from(posts)
|
||||||
|
.leftJoin(users, eq(posts.userId, users.id));
|
||||||
|
|
||||||
|
return allPosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple joins
|
||||||
|
export async function getPostsWithAuthorsAndComments() {
|
||||||
|
const data = await db
|
||||||
|
.select({
|
||||||
|
postId: posts.id,
|
||||||
|
postTitle: posts.title,
|
||||||
|
authorName: users.name,
|
||||||
|
commentCount: comments.id // Will need aggregation for actual count
|
||||||
|
})
|
||||||
|
.from(posts)
|
||||||
|
.innerJoin(users, eq(posts.userId, users.id))
|
||||||
|
.leftJoin(comments, eq(comments.postId, posts.id));
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INSERT QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Simple insert
|
||||||
|
export async function createUser(name: string, email: string) {
|
||||||
|
const [newUser] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
name,
|
||||||
|
email
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return newUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert multiple rows
|
||||||
|
export async function createMultipleUsers(userData: Array<{ name: string; email: string }>) {
|
||||||
|
const newUsers = await db
|
||||||
|
.insert(users)
|
||||||
|
.values(userData)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return newUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert with default values
|
||||||
|
export async function createPost(userId: number, title: string, content: string) {
|
||||||
|
const [newPost] = await db
|
||||||
|
.insert(posts)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
content
|
||||||
|
// createdAt will use default NOW()
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return newPost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UPDATE QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Simple update
|
||||||
|
export async function updateUserName(id: number, newName: string) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(users)
|
||||||
|
.set({ name: newName })
|
||||||
|
.where(eq(users.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update multiple fields
|
||||||
|
export async function updateUser(id: number, updates: { name?: string; email?: string; avatarUrl?: string }) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(users)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(users.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional update
|
||||||
|
export async function publishPost(postId: number, userId: number) {
|
||||||
|
// Only allow user to publish their own post
|
||||||
|
const [published] = await db
|
||||||
|
.update(posts)
|
||||||
|
.set({ published: true, publishedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(posts.id, postId),
|
||||||
|
eq(posts.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return published || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DELETE QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Simple delete
|
||||||
|
export async function deleteUser(id: number) {
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(users)
|
||||||
|
.where(eq(users.id, id))
|
||||||
|
.returning({ id: users.id });
|
||||||
|
|
||||||
|
return deleted ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional delete
|
||||||
|
export async function deleteOldPosts(daysAgo: number) {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - daysAgo);
|
||||||
|
|
||||||
|
const deleted = await db
|
||||||
|
.delete(posts)
|
||||||
|
.where(lt(posts.createdAt, cutoffDate))
|
||||||
|
.returning({ id: posts.id });
|
||||||
|
|
||||||
|
return deleted.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete with complex conditions
|
||||||
|
export async function deleteUnpublishedDrafts(userId: number, daysOld: number) {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
||||||
|
|
||||||
|
const deleted = await db
|
||||||
|
.delete(posts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(posts.userId, userId),
|
||||||
|
eq(posts.published, false),
|
||||||
|
lt(posts.createdAt, cutoffDate)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: posts.id });
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TRANSACTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Transaction example: Transfer credits between users
|
||||||
|
export async function transferCredits(fromUserId: number, toUserId: number, amount: number) {
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
// Get sender's balance
|
||||||
|
const [sender] = await tx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, fromUserId))
|
||||||
|
.for('update'); // Lock row
|
||||||
|
|
||||||
|
if (!sender || sender.credits < amount) {
|
||||||
|
throw new Error('Insufficient credits');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct from sender
|
||||||
|
const [updatedSender] = await tx
|
||||||
|
.update(users)
|
||||||
|
.set({ credits: sender.credits - amount })
|
||||||
|
.where(eq(users.id, fromUserId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Add to recipient
|
||||||
|
const [updatedRecipient] = await tx
|
||||||
|
.update(users)
|
||||||
|
.set({ credits: tx.sql`${users.credits} + ${amount}` })
|
||||||
|
.where(eq(users.id, toUserId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Log transaction
|
||||||
|
await tx.insert(creditTransfers).values({
|
||||||
|
fromUserId,
|
||||||
|
toUserId,
|
||||||
|
amount
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sender: updatedSender, recipient: updatedRecipient };
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction with rollback
|
||||||
|
export async function createUserWithProfile(userData: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
bio?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
// Create user
|
||||||
|
const [user] = await tx
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
name: userData.name,
|
||||||
|
email: userData.email
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Create profile
|
||||||
|
const [profile] = await tx
|
||||||
|
.insert(profiles)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
bio: userData.bio || ''
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return { user, profile };
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
// Transaction automatically rolls back on error
|
||||||
|
console.error('Failed to create user with profile:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AGGREGATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Count
|
||||||
|
export async function countUsers() {
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(users);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by
|
||||||
|
export async function getPostCountByUser() {
|
||||||
|
const counts = await db
|
||||||
|
.select({
|
||||||
|
userId: posts.userId,
|
||||||
|
userName: users.name,
|
||||||
|
postCount: sql<number>`count(${posts.id})`
|
||||||
|
})
|
||||||
|
.from(posts)
|
||||||
|
.innerJoin(users, eq(posts.userId, users.id))
|
||||||
|
.groupBy(posts.userId, users.name);
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Having clause
|
||||||
|
export async function getUsersWithMultiplePosts(minPosts: number) {
|
||||||
|
const users = await db
|
||||||
|
.select({
|
||||||
|
userId: posts.userId,
|
||||||
|
userName: users.name,
|
||||||
|
postCount: sql<number>`count(${posts.id})`
|
||||||
|
})
|
||||||
|
.from(posts)
|
||||||
|
.innerJoin(users, eq(posts.userId, users.id))
|
||||||
|
.groupBy(posts.userId, users.name)
|
||||||
|
.having(sql`count(${posts.id}) >= ${minPosts}`);
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PAGINATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getPaginatedPosts(page: number = 1, pageSize: number = 20) {
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const [{ total }] = await db
|
||||||
|
.select({ total: sql<number>`count(*)` })
|
||||||
|
.from(posts);
|
||||||
|
|
||||||
|
// Get page data
|
||||||
|
const postsData = await db
|
||||||
|
.select()
|
||||||
|
.from(posts)
|
||||||
|
.orderBy(desc(posts.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts: postsData,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
hasNext: page * pageSize < total,
|
||||||
|
hasPrev: page > 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ADVANCED QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Subquery
|
||||||
|
export async function getUsersWithNoPosts() {
|
||||||
|
const usersWithoutPosts = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
sql`${users.id} NOT IN (SELECT DISTINCT user_id FROM posts)`
|
||||||
|
);
|
||||||
|
|
||||||
|
return usersWithoutPosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw SQL with Drizzle (when needed)
|
||||||
|
export async function customQuery(userId: number) {
|
||||||
|
const result = await db.execute(
|
||||||
|
sql`
|
||||||
|
SELECT u.*, COUNT(p.id) as post_count
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN posts p ON p.user_id = u.id
|
||||||
|
WHERE u.id = ${userId}
|
||||||
|
GROUP BY u.id
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepared statement (performance optimization)
|
||||||
|
const getUserByEmailPrepared = db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, sql.placeholder('email')))
|
||||||
|
.prepare('get_user_by_email');
|
||||||
|
|
||||||
|
export async function getUserByEmail(email: string) {
|
||||||
|
const [user] = await getUserByEmailPrepared.execute({ email });
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USAGE EXAMPLE: Cloudflare Worker with Drizzle
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
// db/index.ts
|
||||||
|
import { drizzle } from 'drizzle-orm/neon-http';
|
||||||
|
import { neon } from '@neondatabase/serverless';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
export function getDb(databaseUrl: string) {
|
||||||
|
const sql = neon(databaseUrl);
|
||||||
|
return drizzle(sql, { schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/index.ts
|
||||||
|
import { getDb } from './db';
|
||||||
|
import { users } from './db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
interface Env {
|
||||||
|
DATABASE_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
const db = getDb(env.DATABASE_URL);
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === '/users' && request.method === 'GET') {
|
||||||
|
const allUsers = await db.select().from(users);
|
||||||
|
return Response.json(allUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/users/') && request.method === 'GET') {
|
||||||
|
const id = parseInt(url.pathname.split('/')[2]);
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.id, id));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return new Response('User not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/users' && request.method === 'POST') {
|
||||||
|
const { name, email } = await request.json();
|
||||||
|
const [user] = await db.insert(users).values({ name, email }).returning();
|
||||||
|
return Response.json(user, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
*/
|
||||||
178
templates/drizzle-schema.ts
Normal file
178
templates/drizzle-schema.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Complete Drizzle Schema Template for Neon/Vercel Postgres
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. Copy this file to your project: cp assets/drizzle-schema.ts db/schema.ts
|
||||||
|
* 2. Customize tables to match your app's data model
|
||||||
|
* 3. Generate migrations: npx drizzle-kit generate
|
||||||
|
* 4. Apply migrations: npx drizzle-kit migrate
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pgTable, serial, text, timestamp, integer, boolean, jsonb, index, unique } from 'drizzle-orm/pg-core';
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USERS TABLE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
avatar: text('avatar'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
// Indexes for common queries
|
||||||
|
emailIdx: index('users_email_idx').on(table.email),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// POSTS TABLE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const posts = pgTable('posts', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
content: text('content'),
|
||||||
|
published: boolean('published').default(false).notNull(),
|
||||||
|
slug: text('slug').notNull().unique(),
|
||||||
|
metadata: jsonb('metadata').$type<{
|
||||||
|
views?: number;
|
||||||
|
likes?: number;
|
||||||
|
tags?: string[];
|
||||||
|
}>(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
// Indexes for common queries
|
||||||
|
userIdIdx: index('posts_user_id_idx').on(table.userId),
|
||||||
|
slugIdx: index('posts_slug_idx').on(table.slug),
|
||||||
|
publishedIdx: index('posts_published_idx').on(table.published),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMMENTS TABLE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const comments = pgTable('comments', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
postId: integer('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
// Indexes for common queries
|
||||||
|
postIdIdx: index('comments_post_id_idx').on(table.postId),
|
||||||
|
userIdIdx: index('comments_user_id_idx').on(table.userId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RELATIONS (for Drizzle query API)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
|
posts: many(posts),
|
||||||
|
comments: many(comments),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const postsRelations = relations(posts, ({ one, many }) => ({
|
||||||
|
author: one(users, {
|
||||||
|
fields: [posts.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
comments: many(comments),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const commentsRelations = relations(comments, ({ one }) => ({
|
||||||
|
post: one(posts, {
|
||||||
|
fields: [comments.postId],
|
||||||
|
references: [posts.id],
|
||||||
|
}),
|
||||||
|
author: one(users, {
|
||||||
|
fields: [comments.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPE EXPORTS (for TypeScript)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type NewUser = typeof users.$inferInsert;
|
||||||
|
|
||||||
|
export type Post = typeof posts.$inferSelect;
|
||||||
|
export type NewPost = typeof posts.$inferInsert;
|
||||||
|
|
||||||
|
export type Comment = typeof comments.$inferSelect;
|
||||||
|
export type NewComment = typeof comments.$inferInsert;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USAGE EXAMPLE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* db/index.ts:
|
||||||
|
*
|
||||||
|
* import { drizzle } from 'drizzle-orm/neon-http';
|
||||||
|
* import { neon } from '@neondatabase/serverless';
|
||||||
|
* import * as schema from './schema';
|
||||||
|
*
|
||||||
|
* const sql = neon(process.env.DATABASE_URL!);
|
||||||
|
* export const db = drizzle(sql, { schema });
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* drizzle.config.ts:
|
||||||
|
*
|
||||||
|
* import { defineConfig } from 'drizzle-kit';
|
||||||
|
*
|
||||||
|
* export default defineConfig({
|
||||||
|
* schema: './db/schema.ts',
|
||||||
|
* out: './db/migrations',
|
||||||
|
* dialect: 'postgresql',
|
||||||
|
* dbCredentials: {
|
||||||
|
* url: process.env.DATABASE_URL!
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query Examples:
|
||||||
|
*
|
||||||
|
* // SELECT with joins
|
||||||
|
* const postsWithAuthors = await db.query.posts.findMany({
|
||||||
|
* with: {
|
||||||
|
* author: true,
|
||||||
|
* comments: {
|
||||||
|
* with: {
|
||||||
|
* author: true
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // INSERT
|
||||||
|
* const newUser = await db.insert(users).values({
|
||||||
|
* email: 'alice@example.com',
|
||||||
|
* name: 'Alice'
|
||||||
|
* }).returning();
|
||||||
|
*
|
||||||
|
* // UPDATE
|
||||||
|
* await db.update(posts).set({
|
||||||
|
* published: true,
|
||||||
|
* updatedAt: new Date()
|
||||||
|
* }).where(eq(posts.id, postId));
|
||||||
|
*
|
||||||
|
* // DELETE
|
||||||
|
* await db.delete(comments).where(eq(comments.id, commentId));
|
||||||
|
*
|
||||||
|
* // Transaction
|
||||||
|
* await db.transaction(async (tx) => {
|
||||||
|
* const [user] = await tx.insert(users).values({ email, name }).returning();
|
||||||
|
* await tx.insert(posts).values({ userId: user.id, title, content });
|
||||||
|
* });
|
||||||
|
*/
|
||||||
338
templates/neon-basic-queries.ts
Normal file
338
templates/neon-basic-queries.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
// Basic Neon Postgres Queries
|
||||||
|
// Template for raw SQL queries using @neondatabase/serverless
|
||||||
|
|
||||||
|
import { neon } from '@neondatabase/serverless';
|
||||||
|
|
||||||
|
// Initialize connection (in Cloudflare Workers, get from env.DATABASE_URL)
|
||||||
|
const sql = neon(process.env.DATABASE_URL!);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SELECT QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Simple select
|
||||||
|
export async function getUser(id: number) {
|
||||||
|
const users = await sql`
|
||||||
|
SELECT id, name, email, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = ${id}
|
||||||
|
`;
|
||||||
|
return users[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select with multiple conditions
|
||||||
|
export async function searchUsers(searchTerm: string, limit: number = 10) {
|
||||||
|
const users = await sql`
|
||||||
|
SELECT id, name, email
|
||||||
|
FROM users
|
||||||
|
WHERE name ILIKE ${'%' + searchTerm + '%'}
|
||||||
|
OR email ILIKE ${'%' + searchTerm + '%'}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select with join
|
||||||
|
export async function getPostsWithAuthors(userId?: number) {
|
||||||
|
if (userId) {
|
||||||
|
return await sql`
|
||||||
|
SELECT
|
||||||
|
posts.id,
|
||||||
|
posts.title,
|
||||||
|
posts.content,
|
||||||
|
posts.created_at,
|
||||||
|
users.name as author_name,
|
||||||
|
users.email as author_email
|
||||||
|
FROM posts
|
||||||
|
INNER JOIN users ON posts.user_id = users.id
|
||||||
|
WHERE posts.user_id = ${userId}
|
||||||
|
ORDER BY posts.created_at DESC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sql`
|
||||||
|
SELECT
|
||||||
|
posts.id,
|
||||||
|
posts.title,
|
||||||
|
posts.content,
|
||||||
|
posts.created_at,
|
||||||
|
users.name as author_name,
|
||||||
|
users.email as author_email
|
||||||
|
FROM posts
|
||||||
|
INNER JOIN users ON posts.user_id = users.id
|
||||||
|
ORDER BY posts.created_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregation query
|
||||||
|
export async function getUserStats(userId: number) {
|
||||||
|
const stats = await sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int as post_count,
|
||||||
|
MAX(created_at) as last_post_at
|
||||||
|
FROM posts
|
||||||
|
WHERE user_id = ${userId}
|
||||||
|
`;
|
||||||
|
return stats[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INSERT QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Simple insert with RETURNING
|
||||||
|
export async function createUser(name: string, email: string) {
|
||||||
|
const [user] = await sql`
|
||||||
|
INSERT INTO users (name, email)
|
||||||
|
VALUES (${name}, ${email})
|
||||||
|
RETURNING id, name, email, created_at
|
||||||
|
`;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert
|
||||||
|
export async function createUsers(users: Array<{ name: string; email: string }>) {
|
||||||
|
// Note: For large batches, consider inserting in chunks
|
||||||
|
const values = users.map((user) => [user.name, user.email]);
|
||||||
|
|
||||||
|
const inserted = await sql`
|
||||||
|
INSERT INTO users (name, email)
|
||||||
|
SELECT * FROM UNNEST(
|
||||||
|
${values.map(v => v[0])}::text[],
|
||||||
|
${values.map(v => v[1])}::text[]
|
||||||
|
)
|
||||||
|
RETURNING id, name, email
|
||||||
|
`;
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UPDATE QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Simple update
|
||||||
|
export async function updateUser(id: number, name: string, email: string) {
|
||||||
|
const [updated] = await sql`
|
||||||
|
UPDATE users
|
||||||
|
SET name = ${name}, email = ${email}
|
||||||
|
WHERE id = ${id}
|
||||||
|
RETURNING id, name, email, created_at
|
||||||
|
`;
|
||||||
|
return updated || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial update
|
||||||
|
export async function updateUserPartial(id: number, updates: { name?: string; email?: string }) {
|
||||||
|
// Only update provided fields
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (updates.name !== undefined) {
|
||||||
|
setClauses.push(`name = $${values.length + 1}`);
|
||||||
|
values.push(updates.name);
|
||||||
|
}
|
||||||
|
if (updates.email !== undefined) {
|
||||||
|
setClauses.push(`email = $${values.length + 1}`);
|
||||||
|
values.push(updates.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setClauses.length === 0) {
|
||||||
|
throw new Error('No fields to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
// Note: Template literals don't support dynamic SET clauses well
|
||||||
|
// Use Drizzle ORM for more complex partial updates
|
||||||
|
const [updated] = await sql`
|
||||||
|
UPDATE users
|
||||||
|
SET ${sql(setClauses.join(', '))}
|
||||||
|
WHERE id = ${id}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return updated || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DELETE QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Simple delete
|
||||||
|
export async function deleteUser(id: number) {
|
||||||
|
const [deleted] = await sql`
|
||||||
|
DELETE FROM users
|
||||||
|
WHERE id = ${id}
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
return deleted ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete with condition
|
||||||
|
export async function deleteOldPosts(daysAgo: number) {
|
||||||
|
const deleted = await sql`
|
||||||
|
DELETE FROM posts
|
||||||
|
WHERE created_at < NOW() - INTERVAL '${daysAgo} days'
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
return deleted.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TRANSACTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Automatic transaction (recommended)
|
||||||
|
export async function transferCredits(fromUserId: number, toUserId: number, amount: number) {
|
||||||
|
const result = await sql.transaction(async (tx) => {
|
||||||
|
// Deduct from sender
|
||||||
|
const [sender] = await tx`
|
||||||
|
UPDATE accounts
|
||||||
|
SET balance = balance - ${amount}
|
||||||
|
WHERE user_id = ${fromUserId} AND balance >= ${amount}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!sender) {
|
||||||
|
throw new Error('Insufficient balance');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to recipient
|
||||||
|
const [recipient] = await tx`
|
||||||
|
UPDATE accounts
|
||||||
|
SET balance = balance + ${amount}
|
||||||
|
WHERE user_id = ${toUserId}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Log transaction
|
||||||
|
await tx`
|
||||||
|
INSERT INTO transfers (from_user_id, to_user_id, amount)
|
||||||
|
VALUES (${fromUserId}, ${toUserId}, ${amount})
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { sender, recipient };
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PAGINATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getPaginatedPosts(page: number = 1, pageSize: number = 20) {
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const [{ total }] = await sql`
|
||||||
|
SELECT COUNT(*)::int as total FROM posts
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Get page data
|
||||||
|
const posts = await sql`
|
||||||
|
SELECT id, title, content, user_id, created_at
|
||||||
|
FROM posts
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${pageSize}
|
||||||
|
OFFSET ${offset}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
hasNext: page * pageSize < total,
|
||||||
|
hasPrev: page > 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FULL-TEXT SEARCH (Postgres-specific)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function searchPosts(query: string, limit: number = 10) {
|
||||||
|
const posts = await sql`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
ts_rank(to_tsvector('english', title || ' ' || content), plainto_tsquery('english', ${query})) as rank
|
||||||
|
FROM posts
|
||||||
|
WHERE to_tsvector('english', title || ' ' || content) @@ plainto_tsquery('english', ${query})
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USAGE EXAMPLES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Example: Cloudflare Worker
|
||||||
|
/*
|
||||||
|
import { neon } from '@neondatabase/serverless';
|
||||||
|
|
||||||
|
interface Env {
|
||||||
|
DATABASE_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
const sql = neon(env.DATABASE_URL);
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === '/users' && request.method === 'GET') {
|
||||||
|
const users = await sql`SELECT * FROM users LIMIT 10`;
|
||||||
|
return Response.json(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/users' && request.method === 'POST') {
|
||||||
|
const { name, email } = await request.json();
|
||||||
|
const [user] = await sql`
|
||||||
|
INSERT INTO users (name, email)
|
||||||
|
VALUES (${name}, ${email})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return Response.json(user, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Example: Next.js Server Action
|
||||||
|
/*
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { neon } from '@neondatabase/serverless';
|
||||||
|
|
||||||
|
const sql = neon(process.env.DATABASE_URL!);
|
||||||
|
|
||||||
|
export async function getUsers() {
|
||||||
|
const users = await sql`SELECT * FROM users ORDER BY created_at DESC`;
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(formData: FormData) {
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
|
||||||
|
const [user] = await sql`
|
||||||
|
INSERT INTO users (name, email)
|
||||||
|
VALUES (${name}, ${email})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
revalidatePath('/users');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
*/
|
||||||
25
templates/package.json
Normal file
25
templates/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "neon-postgres-project",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Neon Postgres with Drizzle ORM",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:migrate:prod": "tsx scripts/migrate.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@neondatabase/serverless": "^1.0.2",
|
||||||
|
"drizzle-orm": "^0.44.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"drizzle-kit": "^0.31.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user