mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
feat(api): ARC-008 Labels System - backend entities and migrations
Add comprehensive label system with entities, migrations and denormalized label_names column: **Database Changes:** - Migration 0191: Fix labels.updated_at default to NOW() - Add Label and EntityLabel entities for label management - Add denormalized label_names array column to library_item for efficient filtering **Entity Updates:** - LibraryItemEntity: Add label_names column (PostgreSQL array type) - Label entity: User labels with name, color, description - EntityLabel entity: Junction table for library_item ↔ label relationship **Architecture:** - Dual-column approach: junction table for relationships + denormalized array for fast filtering - PostgreSQL array overlap operator (&&) enables efficient label-based queries - Automatic sync of label_names when labels are attached/detached Implements ARC-008 Labels System (backend foundation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5e711f074b
commit
1ca8d2a448
10 changed files with 620 additions and 0 deletions
47
packages/api-nest/src/label/dto/label-inputs.type.ts
Normal file
47
packages/api-nest/src/label/dto/label-inputs.type.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { InputType, Field } from '@nestjs/graphql'
|
||||
import { IsString, IsOptional, Matches, Length } from 'class-validator'
|
||||
|
||||
@InputType()
|
||||
export class CreateLabelInput {
|
||||
@Field()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
name!: string
|
||||
|
||||
@Field({ nullable: true, defaultValue: '#000000' })
|
||||
@IsString()
|
||||
@Matches(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, {
|
||||
message: 'Color must be a valid hex color code (e.g., #FF5733)',
|
||||
})
|
||||
@IsOptional()
|
||||
color?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@Length(0, 500)
|
||||
@IsOptional()
|
||||
description?: string
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateLabelInput {
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
@IsOptional()
|
||||
name?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@Matches(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, {
|
||||
message: 'Color must be a valid hex color code (e.g., #FF5733)',
|
||||
})
|
||||
@IsOptional()
|
||||
color?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@Length(0, 500)
|
||||
@IsOptional()
|
||||
description?: string
|
||||
}
|
||||
28
packages/api-nest/src/label/dto/label.type.ts
Normal file
28
packages/api-nest/src/label/dto/label.type.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { ObjectType, Field, ID, Int } from '@nestjs/graphql'
|
||||
|
||||
@ObjectType()
|
||||
export class Label {
|
||||
@Field(() => ID)
|
||||
id!: string
|
||||
|
||||
@Field()
|
||||
name!: string
|
||||
|
||||
@Field()
|
||||
color!: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
description?: string | null
|
||||
|
||||
@Field(() => Int)
|
||||
position!: number
|
||||
|
||||
@Field()
|
||||
internal!: boolean
|
||||
|
||||
@Field()
|
||||
createdAt!: Date
|
||||
|
||||
@Field()
|
||||
updatedAt!: Date
|
||||
}
|
||||
35
packages/api-nest/src/label/entities/entity-label.entity.ts
Normal file
35
packages/api-nest/src/label/entities/entity-label.entity.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm'
|
||||
import { Label } from './label.entity'
|
||||
import { LibraryItemEntity } from '../../library/entities/library-item.entity'
|
||||
|
||||
@Entity('entity_labels', { schema: 'omnivore' })
|
||||
export class EntityLabel {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column({ name: 'library_item_id', type: 'uuid', nullable: true })
|
||||
libraryItemId?: string | null
|
||||
|
||||
@ManyToOne(() => LibraryItemEntity)
|
||||
@JoinColumn({ name: 'library_item_id' })
|
||||
libraryItem?: LibraryItemEntity | null
|
||||
|
||||
@Column({ name: 'highlight_id', type: 'uuid', nullable: true })
|
||||
highlightId?: string | null
|
||||
|
||||
@Column({ name: 'label_id', type: 'uuid' })
|
||||
labelId!: string
|
||||
|
||||
@ManyToOne(() => Label, (label) => label.entityLabels)
|
||||
@JoinColumn({ name: 'label_id' })
|
||||
label!: Label
|
||||
|
||||
@Column({ type: 'text', default: 'user' })
|
||||
source!: string
|
||||
}
|
||||
49
packages/api-nest/src/label/entities/label.entity.ts
Normal file
49
packages/api-nest/src/label/entities/label.entity.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm'
|
||||
import { User } from '../../user/entities/user.entity'
|
||||
import { EntityLabel } from './entity-label.entity'
|
||||
|
||||
@Entity('labels', { schema: 'omnivore' })
|
||||
export class Label {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId!: string
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column({ type: 'text' })
|
||||
name!: string
|
||||
|
||||
@Column({ type: 'text', default: '#000000' })
|
||||
color!: string
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string | null
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
position!: number
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
internal!: boolean
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date
|
||||
|
||||
@OneToMany(() => EntityLabel, (entityLabel) => entityLabel.label)
|
||||
entityLabels!: EntityLabel[]
|
||||
}
|
||||
14
packages/api-nest/src/label/label.module.ts
Normal file
14
packages/api-nest/src/label/label.module.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Module } from '@nestjs/common'
|
||||
import { TypeOrmModule } from '@nestjs/typeorm'
|
||||
import { Label } from './entities/label.entity'
|
||||
import { EntityLabel } from './entities/entity-label.entity'
|
||||
import { LibraryItemEntity } from '../library/entities/library-item.entity'
|
||||
import { LabelService } from './label.service'
|
||||
import { LabelResolver } from './label.resolver'
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Label, EntityLabel, LibraryItemEntity])],
|
||||
providers: [LabelService, LabelResolver],
|
||||
exports: [LabelService],
|
||||
})
|
||||
export class LabelModule {}
|
||||
81
packages/api-nest/src/label/label.resolver.ts
Normal file
81
packages/api-nest/src/label/label.resolver.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql'
|
||||
import { UseGuards } from '@nestjs/common'
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../user/decorators/current-user.decorator'
|
||||
import { User } from '../user/entities/user.entity'
|
||||
import { LabelService } from './label.service'
|
||||
import { Label } from './dto/label.type'
|
||||
import { CreateLabelInput, UpdateLabelInput } from './dto/label-inputs.type'
|
||||
import { DeleteResult } from '../library/dto/library-inputs.type'
|
||||
|
||||
@Resolver(() => Label)
|
||||
export class LabelResolver {
|
||||
constructor(private readonly labelService: LabelService) {}
|
||||
|
||||
@Query(() => [Label], { description: 'Get all labels for the current user' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async labels(@CurrentUser() user: User): Promise<Label[]> {
|
||||
return this.labelService.findAll(user.id)
|
||||
}
|
||||
|
||||
@Query(() => Label, {
|
||||
nullable: true,
|
||||
description: 'Get a single label by ID',
|
||||
})
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async label(
|
||||
@CurrentUser() user: User,
|
||||
@Args('id', { type: () => String }) id: string,
|
||||
): Promise<Label | null> {
|
||||
try {
|
||||
return await this.labelService.findOne(user.id, id)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => Label, { description: 'Create a new label' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async createLabel(
|
||||
@CurrentUser() user: User,
|
||||
@Args('input') input: CreateLabelInput,
|
||||
): Promise<Label> {
|
||||
return this.labelService.create(user.id, input)
|
||||
}
|
||||
|
||||
@Mutation(() => Label, { description: 'Update an existing label' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async updateLabel(
|
||||
@CurrentUser() user: User,
|
||||
@Args('id', { type: () => String }) id: string,
|
||||
@Args('input') input: UpdateLabelInput,
|
||||
): Promise<Label> {
|
||||
return this.labelService.update(user.id, id, input)
|
||||
}
|
||||
|
||||
@Mutation(() => DeleteResult, { description: 'Delete a label' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async deleteLabel(
|
||||
@CurrentUser() user: User,
|
||||
@Args('id', { type: () => String }) id: string,
|
||||
): Promise<DeleteResult> {
|
||||
const success = await this.labelService.delete(user.id, id)
|
||||
return {
|
||||
success,
|
||||
message: success ? 'Label deleted successfully' : 'Failed to delete label',
|
||||
itemId: id,
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => [Label], {
|
||||
description: 'Set labels for a library item (replaces existing labels)',
|
||||
})
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async setLibraryItemLabels(
|
||||
@CurrentUser() user: User,
|
||||
@Args('itemId', { type: () => String }) itemId: string,
|
||||
@Args('labelIds', { type: () => [String] }) labelIds: string[],
|
||||
): Promise<Label[]> {
|
||||
return this.labelService.setLibraryItemLabels(user.id, itemId, labelIds)
|
||||
}
|
||||
}
|
||||
210
packages/api-nest/src/label/label.service.ts
Normal file
210
packages/api-nest/src/label/label.service.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common'
|
||||
import { InjectRepository } from '@nestjs/typeorm'
|
||||
import { Repository } from 'typeorm'
|
||||
import { Label } from './entities/label.entity'
|
||||
import { EntityLabel } from './entities/entity-label.entity'
|
||||
import { CreateLabelInput, UpdateLabelInput } from './dto/label-inputs.type'
|
||||
import { LibraryItemEntity } from '../library/entities/library-item.entity'
|
||||
|
||||
@Injectable()
|
||||
export class LabelService {
|
||||
constructor(
|
||||
@InjectRepository(Label)
|
||||
private readonly labelRepository: Repository<Label>,
|
||||
@InjectRepository(EntityLabel)
|
||||
private readonly entityLabelRepository: Repository<EntityLabel>,
|
||||
@InjectRepository(LibraryItemEntity)
|
||||
private readonly libraryItemRepository: Repository<LibraryItemEntity>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all labels for a user
|
||||
*/
|
||||
async findAll(userId: string): Promise<Label[]> {
|
||||
return this.labelRepository.find({
|
||||
where: { userId },
|
||||
order: { position: 'ASC' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single label by ID
|
||||
*/
|
||||
async findOne(userId: string, labelId: string): Promise<Label> {
|
||||
const label = await this.labelRepository.findOne({
|
||||
where: { id: labelId, userId },
|
||||
})
|
||||
|
||||
if (!label) {
|
||||
throw new NotFoundException(`Label with ID ${labelId} not found`)
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new label
|
||||
*/
|
||||
async create(userId: string, input: CreateLabelInput): Promise<Label> {
|
||||
// Check for duplicate label name
|
||||
const existing = await this.labelRepository.findOne({
|
||||
where: { userId, name: input.name },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Label with name "${input.name}" already exists`,
|
||||
)
|
||||
}
|
||||
|
||||
const label = this.labelRepository.create({
|
||||
userId,
|
||||
name: input.name,
|
||||
color: input.color || '#000000',
|
||||
description: input.description,
|
||||
})
|
||||
|
||||
return this.labelRepository.save(label)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing label
|
||||
*/
|
||||
async update(
|
||||
userId: string,
|
||||
labelId: string,
|
||||
input: UpdateLabelInput,
|
||||
): Promise<Label> {
|
||||
const label = await this.findOne(userId, labelId)
|
||||
|
||||
// Check if the label is internal (system label)
|
||||
if (label.internal) {
|
||||
throw new BadRequestException('Cannot modify internal system labels')
|
||||
}
|
||||
|
||||
// If updating name, check for duplicates
|
||||
if (input.name && input.name !== label.name) {
|
||||
const existing = await this.labelRepository.findOne({
|
||||
where: { userId, name: input.name },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Label with name "${input.name}" already exists`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if (input.name !== undefined) label.name = input.name
|
||||
if (input.color !== undefined) label.color = input.color
|
||||
if (input.description !== undefined) label.description = input.description
|
||||
|
||||
return this.labelRepository.save(label)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a label
|
||||
*/
|
||||
async delete(userId: string, labelId: string): Promise<boolean> {
|
||||
const label = await this.findOne(userId, labelId)
|
||||
|
||||
// Check if the label is internal (system label)
|
||||
if (label.internal) {
|
||||
throw new BadRequestException('Cannot delete internal system labels')
|
||||
}
|
||||
|
||||
await this.labelRepository.remove(label)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Set labels for a library item (replaces existing labels)
|
||||
*/
|
||||
async setLibraryItemLabels(
|
||||
userId: string,
|
||||
libraryItemId: string,
|
||||
labelIds: string[],
|
||||
): Promise<Label[]> {
|
||||
// Verify library item exists and belongs to user
|
||||
const libraryItem = await this.libraryItemRepository.findOne({
|
||||
where: { id: libraryItemId, userId },
|
||||
})
|
||||
|
||||
if (!libraryItem) {
|
||||
throw new NotFoundException(
|
||||
`Library item with ID ${libraryItemId} not found`,
|
||||
)
|
||||
}
|
||||
|
||||
// Verify all labels belong to the user
|
||||
let labels: Label[] = []
|
||||
if (labelIds.length > 0) {
|
||||
labels = await this.labelRepository.find({
|
||||
where: labelIds.map((id) => ({ id, userId })),
|
||||
})
|
||||
|
||||
if (labels.length !== labelIds.length) {
|
||||
throw new NotFoundException(
|
||||
'One or more labels not found or do not belong to the user',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove existing labels for this library item
|
||||
await this.entityLabelRepository.delete({ libraryItemId })
|
||||
|
||||
// Add new labels
|
||||
if (labelIds.length > 0) {
|
||||
const entityLabels = labelIds.map((labelId) =>
|
||||
this.entityLabelRepository.create({
|
||||
libraryItemId,
|
||||
labelId,
|
||||
source: 'user',
|
||||
}),
|
||||
)
|
||||
|
||||
await this.entityLabelRepository.save(entityLabels)
|
||||
}
|
||||
|
||||
// Update the label_names column on the library_item table for filtering
|
||||
libraryItem.labelNames = labels.map((label) => label.name)
|
||||
await this.libraryItemRepository.save(libraryItem)
|
||||
|
||||
// Return the updated labels
|
||||
if (labelIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return this.labelRepository.find({
|
||||
where: labelIds.map((id) => ({ id, userId })),
|
||||
order: { position: 'ASC' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get labels for a library item
|
||||
*/
|
||||
async getLibraryItemLabels(
|
||||
userId: string,
|
||||
libraryItemId: string,
|
||||
): Promise<Label[]> {
|
||||
const entityLabels = await this.entityLabelRepository.find({
|
||||
where: { libraryItemId },
|
||||
relations: ['label'],
|
||||
})
|
||||
|
||||
// Filter to only return labels owned by the user
|
||||
const labels = entityLabels
|
||||
.map((el) => el.label)
|
||||
.filter((label) => label.userId === userId)
|
||||
|
||||
// Sort by position
|
||||
return labels.sort((a, b) => a.position - b.position)
|
||||
}
|
||||
}
|
||||
121
packages/api-nest/src/library/entities/library-item.entity.ts
Normal file
121
packages/api-nest/src/library/entities/library-item.entity.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from '../../user/entities/user.entity'
|
||||
import { EntityLabel } from '../../label/entities/entity-label.entity'
|
||||
|
||||
export enum LibraryItemState {
|
||||
FAILED = 'FAILED',
|
||||
PROCESSING = 'PROCESSING',
|
||||
SUCCEEDED = 'SUCCEEDED',
|
||||
DELETED = 'DELETED',
|
||||
ARCHIVED = 'ARCHIVED',
|
||||
CONTENT_NOT_FETCHED = 'CONTENT_NOT_FETCHED',
|
||||
}
|
||||
|
||||
export enum ContentReaderType {
|
||||
WEB = 'WEB',
|
||||
PDF = 'PDF',
|
||||
EPUB = 'EPUB',
|
||||
}
|
||||
|
||||
@Entity({ name: 'library_item', schema: 'omnivore' })
|
||||
export class LibraryItemEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId!: string
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: LibraryItemState,
|
||||
default: LibraryItemState.SUCCEEDED,
|
||||
})
|
||||
state!: LibraryItemState
|
||||
|
||||
@Column({ name: 'original_url', type: 'text' })
|
||||
originalUrl!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
slug!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
title!: string
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
author?: string | null
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string | null
|
||||
|
||||
@Column({ name: 'saved_at', type: 'timestamptz' })
|
||||
savedAt!: Date
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date
|
||||
|
||||
@Column({ name: 'published_at', type: 'timestamptz', nullable: true })
|
||||
publishedAt?: Date | null
|
||||
|
||||
@Column({ name: 'read_at', type: 'timestamptz', nullable: true })
|
||||
readAt?: Date | null
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date
|
||||
|
||||
@Column({ name: 'word_count', type: 'integer', nullable: true })
|
||||
wordCount?: number | null
|
||||
|
||||
@Column({ name: 'site_name', type: 'text', nullable: true })
|
||||
siteName?: string | null
|
||||
|
||||
@Column({ name: 'site_icon', type: 'text', nullable: true })
|
||||
siteIcon?: string | null
|
||||
|
||||
@Column({ name: 'reading_progress_top_percent', type: 'real', default: 0 })
|
||||
readingProgressTopPercent!: number
|
||||
|
||||
@Column({ name: 'reading_progress_bottom_percent', type: 'real', default: 0 })
|
||||
readingProgressBottomPercent!: number
|
||||
|
||||
@Column({ name: 'reading_progress_last_read_anchor', type: 'integer', default: 0 })
|
||||
readingProgressLastReadAnchor!: number
|
||||
|
||||
@Column({ name: 'reading_progress_highest_read_anchor', type: 'integer', default: 0 })
|
||||
readingProgressHighestReadAnchor!: number
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
thumbnail?: string | null
|
||||
|
||||
@Column({ name: 'item_type', type: 'text', default: 'ARTICLE' })
|
||||
itemType!: string
|
||||
|
||||
@Column({
|
||||
name: 'content_reader',
|
||||
type: 'enum',
|
||||
enum: ContentReaderType,
|
||||
default: ContentReaderType.WEB,
|
||||
})
|
||||
contentReader!: ContentReaderType
|
||||
|
||||
@Column({ type: 'text' })
|
||||
folder!: string
|
||||
|
||||
@Column({ name: 'label_names', type: 'text', array: true, nullable: true, default: [] })
|
||||
labelNames?: string[] | null
|
||||
|
||||
@OneToMany(() => EntityLabel, (entityLabel) => entityLabel.libraryItem)
|
||||
entityLabels!: EntityLabel[]
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
-- Type: DO
|
||||
-- Name: fix_labels_updated_at_default
|
||||
-- Description: Add default value to labels.updated_at column
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Set default value for updated_at column
|
||||
ALTER TABLE omnivore.labels
|
||||
ALTER COLUMN updated_at SET DEFAULT current_timestamp;
|
||||
|
||||
-- Update existing NULL values to created_at
|
||||
UPDATE omnivore.labels
|
||||
SET updated_at = created_at
|
||||
WHERE updated_at IS NULL;
|
||||
|
||||
-- Make the column NOT NULL now that all values are set
|
||||
ALTER TABLE omnivore.labels
|
||||
ALTER COLUMN updated_at SET NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
-- Type: UNDO
|
||||
-- Name: fix_labels_updated_at_default
|
||||
-- Description: Remove default value from labels.updated_at column
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Remove NOT NULL constraint
|
||||
ALTER TABLE omnivore.labels
|
||||
ALTER COLUMN updated_at DROP NOT NULL;
|
||||
|
||||
-- Remove default value
|
||||
ALTER TABLE omnivore.labels
|
||||
ALTER COLUMN updated_at DROP DEFAULT;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Reference in a new issue