Skip to content

Commit 8ab6ff1

Browse files
authored
feat: add optional database indexes (#3637)
1 parent aac1f38 commit 8ab6ff1

File tree

4 files changed

+335
-0
lines changed

4 files changed

+335
-0
lines changed

docs/content/docs/2.collections/1.define.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,65 @@ export default defineContentConfig({
8686
You can define as many collections as you want to organize different types of content.
8787
::
8888

89+
### Database Indexes
90+
91+
Optimize query performance by defining indexes on collection columns. Indexes are especially useful for fields used in filtering, sorting, or lookups.
92+
93+
```ts [content.config.ts]
94+
import { defineCollection, defineContentConfig } from '@nuxt/content'
95+
import { z } from 'zod'
96+
97+
export default defineContentConfig({
98+
collections: {
99+
products: defineCollection({
100+
type: 'data',
101+
source: 'products/*.json',
102+
schema: z.object({
103+
sku: z.string(),
104+
price: z.number(),
105+
category: z.string(),
106+
inStock: z.boolean(),
107+
}),
108+
indexes: [
109+
// Single column indexes
110+
{ columns: ['category'] },
111+
{ columns: ['price'] },
112+
113+
// Composite index for category + price filtering
114+
{ columns: ['category', 'price'] },
115+
116+
// Unique index to ensure SKU uniqueness
117+
{ columns: ['sku'], unique: true },
118+
119+
// Custom index name (optional)
120+
{ columns: ['inStock', 'category'], name: 'idx_stock_category' },
121+
],
122+
}),
123+
},
124+
})
125+
```
126+
127+
::note
128+
Indexes are created automatically when the database schema is generated. They work across all supported databases: SQLite, Cloudflare D1, PostgreSQL, LibSQL, and PGlite.
129+
::
130+
131+
::tip{icon="i-ph-lightbulb"}
132+
**Cloudflare D1 Cost Optimization**: With indexes, a `WHERE` clause on an indexed column counts as only 1 row read when there's a single match. Without an index, D1 counts all rows scanned in the table, significantly increasing your read costs. Indexes can dramatically reduce your D1 billing.
133+
::
134+
135+
**Index Configuration Options:**
136+
137+
- **`columns`** (required): Array of column names to include in the index
138+
- **`unique`** (optional): Set to `true` to create a unique index (default: `false`)
139+
- **`name`** (optional): Custom index name. If omitted, auto-generates as `idx_{collection}_{column1}_{column2}`
140+
141+
**Performance Tips:**
142+
143+
- Index columns used in `where()` queries for faster filtering
144+
- Index columns used in `sort()` for optimized sorting
145+
- Use composite indexes for queries that filter/sort by multiple columns
146+
- Unique indexes automatically enforce data uniqueness constraints
147+
89148
## Querying Collections
90149

91150
Use the [`queryCollection`](/docs/utils/query-collection) util to fetch one or all items from a collection:
@@ -125,6 +184,17 @@ type Collection = {
125184
source?: string | CollectionSource
126185
// Zod schema for content validation and typing
127186
schema?: ZodObject<T>
187+
// Database indexes for query optimization
188+
indexes?: CollectionIndex[]
189+
}
190+
191+
type CollectionIndex = {
192+
// Column names to include in the index
193+
columns: string[]
194+
// Optional custom index name
195+
name?: string
196+
// Whether this is a unique index (default: false)
197+
unique?: boolean
128198
}
129199
```
130200

src/types/collection.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ export interface Collections {}
77

88
export type CollectionType = 'page' | 'data'
99

10+
/**
11+
* Defines an index on collection columns for optimizing database queries
12+
*/
13+
export interface CollectionIndex {
14+
/**
15+
* Column names to include in the index
16+
*/
17+
columns: string[]
18+
19+
/**
20+
* Optional custom index name
21+
* If not provided, will auto-generate: idx_{collection}_{columns.join('_')}
22+
*/
23+
name?: string
24+
25+
/**
26+
* Whether this is a unique index
27+
* @default false
28+
*/
29+
unique?: boolean
30+
}
31+
1032
export type CollectionSource = {
1133
include: string
1234
prefix?: string
@@ -42,12 +64,14 @@ export interface PageCollection<T> {
4264
type: 'page'
4365
source?: string | CollectionSource | CollectionSource[] | ResolvedCustomCollectionSource
4466
schema?: ContentStandardSchemaV1<T>
67+
indexes?: CollectionIndex[]
4568
}
4669

4770
export interface DataCollection<T> {
4871
type: 'data'
4972
source?: string | CollectionSource | CollectionSource[] | ResolvedCustomCollectionSource
5073
schema: ContentStandardSchemaV1<T>
74+
indexes?: CollectionIndex[]
5175
}
5276

5377
export type Collection<T> = PageCollection<T> | DataCollection<T>
@@ -58,6 +82,7 @@ export interface DefinedCollection {
5882
schema: Draft07
5983
extendedSchema: Draft07
6084
fields: Record<string, 'string' | 'number' | 'boolean' | 'date' | 'json'>
85+
indexes?: CollectionIndex[]
6186
}
6287

6388
export interface ResolvedCollection extends DefinedCollection {

src/utils/collection.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function defineCollection<T>(collection: Collection<T>): DefinedCollectio
3434
schema: standardSchema,
3535
extendedSchema: extendedSchema,
3636
fields: getCollectionFieldsTypes(extendedSchema),
37+
indexes: collection.indexes,
3738
}
3839
}
3940

@@ -235,6 +236,64 @@ export function generateCollectionInsert(collection: ResolvedCollection, data: P
235236
}
236237
}
237238

239+
/**
240+
* Generate a safe index name following SQL naming conventions
241+
* Ensures name is within database limits (PostgreSQL 63 char limit)
242+
*/
243+
function generateIndexName(collectionName: string, columns: string[]): string {
244+
const base = `idx_${collectionName}_${columns.join('_')}`
245+
246+
// Limit to 63 characters (PostgreSQL limit, most restrictive common limit)
247+
// SQLite allows 1024, D1 follows SQLite
248+
if (base.length > 63) {
249+
// Truncate and add hash to ensure uniqueness
250+
const hashSuffix = hash(base).slice(0, 8)
251+
return base.slice(0, 54) + '_' + hashSuffix
252+
}
253+
254+
return base
255+
}
256+
257+
/**
258+
* Generate CREATE INDEX statements for a collection
259+
* Returns array of SQL statements (one per index)
260+
*/
261+
export function generateCollectionIndexStatements(collection: ResolvedCollection): string[] {
262+
if (!collection.indexes || collection.indexes.length === 0) {
263+
return []
264+
}
265+
266+
const statements: string[] = []
267+
268+
for (const index of collection.indexes) {
269+
// Validate columns exist in schema
270+
const invalidColumns = index.columns.filter(
271+
column => !collection.fields[column] && column !== 'id',
272+
)
273+
274+
if (invalidColumns.length > 0) {
275+
logger.warn(
276+
`Index references non-existent column(s) "${invalidColumns.join(', ')}" in collection "${collection.name}". Skipping this index.`,
277+
)
278+
continue
279+
}
280+
281+
// Generate index name
282+
const indexName = index.name || generateIndexName(collection.name, index.columns)
283+
284+
// Quote column names for SQL safety
285+
const quotedColumns = index.columns.map(col => `"${col}"`).join(', ')
286+
287+
// Build CREATE INDEX statement
288+
const uniqueKeyword = index.unique ? 'UNIQUE ' : ''
289+
const statement = `CREATE ${uniqueKeyword}INDEX IF NOT EXISTS ${indexName} ON ${collection.tableName} (${quotedColumns});`
290+
291+
statements.push(statement)
292+
}
293+
294+
return statements
295+
}
296+
238297
// Convert a collection with Zod schema to SQL table definition
239298
export function generateCollectionTableDefinition(collection: ResolvedCollection, opts: { drop?: boolean } = {}) {
240299
const sortedKeys = getOrderedSchemaKeys(collection.extendedSchema)
@@ -281,6 +340,12 @@ export function generateCollectionTableDefinition(collection: ResolvedCollection
281340
definition = `DROP TABLE IF EXISTS ${collection.tableName};\n${definition}`
282341
}
283342

343+
// Add index statements
344+
const indexStatements = generateCollectionIndexStatements(collection)
345+
if (indexStatements.length > 0) {
346+
definition += '\n' + indexStatements.join('\n')
347+
}
348+
284349
return definition
285350
}
286351

0 commit comments

Comments
 (0)