Skip to content

Commit d8c13bf

Browse files
committed
fix project table unique constraint bug
Signed-off-by: phernandez <[email protected]>
1 parent 3f70f5e commit d8c13bf

File tree

5 files changed

+336
-19
lines changed

5 files changed

+336
-19
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""project constraint fix
2+
3+
Revision ID: 647e7a75e2cd
4+
Revises: 5fe1ab1ccebe
5+
Create Date: 2025-06-03 12:48:30.162566
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "647e7a75e2cd"
17+
down_revision: Union[str, None] = "5fe1ab1ccebe"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
"""Remove the problematic UNIQUE constraint on is_default column.
24+
25+
The UNIQUE constraint prevents multiple projects from having is_default=FALSE,
26+
which breaks project creation when the service sets is_default=False.
27+
28+
Since SQLite doesn't support dropping specific constraints easily, we'll
29+
recreate the table without the problematic constraint.
30+
"""
31+
# For SQLite, we need to recreate the table without the UNIQUE constraint
32+
# Create a new table without the UNIQUE constraint on is_default
33+
op.create_table(
34+
"project_new",
35+
sa.Column("id", sa.Integer(), nullable=False),
36+
sa.Column("name", sa.String(), nullable=False),
37+
sa.Column("description", sa.Text(), nullable=True),
38+
sa.Column("permalink", sa.String(), nullable=False),
39+
sa.Column("path", sa.String(), nullable=False),
40+
sa.Column("is_active", sa.Boolean(), nullable=False),
41+
sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
42+
sa.Column("created_at", sa.DateTime(), nullable=False),
43+
sa.Column("updated_at", sa.DateTime(), nullable=False),
44+
sa.PrimaryKeyConstraint("id"),
45+
sa.UniqueConstraint("name"),
46+
sa.UniqueConstraint("permalink"),
47+
)
48+
49+
# Copy data from old table to new table
50+
op.execute("INSERT INTO project_new SELECT * FROM project")
51+
52+
# Drop the old table
53+
op.drop_table("project")
54+
55+
# Rename the new table
56+
op.rename_table("project_new", "project")
57+
58+
# Recreate the indexes
59+
with op.batch_alter_table("project", schema=None) as batch_op:
60+
batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
61+
batch_op.create_index("ix_project_name", ["name"], unique=True)
62+
batch_op.create_index("ix_project_path", ["path"], unique=False)
63+
batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
64+
batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
65+
66+
67+
def downgrade() -> None:
68+
"""Add back the UNIQUE constraint on is_default column.
69+
70+
WARNING: This will break project creation again if multiple projects
71+
have is_default=FALSE.
72+
"""
73+
# Recreate the table with the UNIQUE constraint
74+
op.create_table(
75+
"project_old",
76+
sa.Column("id", sa.Integer(), nullable=False),
77+
sa.Column("name", sa.String(), nullable=False),
78+
sa.Column("description", sa.Text(), nullable=True),
79+
sa.Column("permalink", sa.String(), nullable=False),
80+
sa.Column("path", sa.String(), nullable=False),
81+
sa.Column("is_active", sa.Boolean(), nullable=False),
82+
sa.Column("is_default", sa.Boolean(), nullable=True),
83+
sa.Column("created_at", sa.DateTime(), nullable=False),
84+
sa.Column("updated_at", sa.DateTime(), nullable=False),
85+
sa.PrimaryKeyConstraint("id"),
86+
sa.UniqueConstraint("is_default"), # Add back the problematic constraint
87+
sa.UniqueConstraint("name"),
88+
sa.UniqueConstraint("permalink"),
89+
)
90+
91+
# Copy data (this may fail if multiple FALSE values exist)
92+
op.execute("INSERT INTO project_old SELECT * FROM project")
93+
94+
# Drop the current table and rename
95+
op.drop_table("project")
96+
op.rename_table("project_old", "project")
97+
98+
# Recreate indexes
99+
with op.batch_alter_table("project", schema=None) as batch_op:
100+
batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
101+
batch_op.create_index("ix_project_name", ["name"], unique=True)
102+
batch_op.create_index("ix_project_path", ["path"], unique=False)
103+
batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
104+
batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)

src/basic_memory/api/routers/project_router.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,9 @@ async def add_project(
111111
Response confirming the project was added
112112
"""
113113
try: # pragma: no cover
114-
await project_service.add_project(project_data.name, project_data.path)
115-
116-
if project_data.set_default: # pragma: no cover
117-
await project_service.set_default_project(project_data.name)
114+
await project_service.add_project(
115+
project_data.name, project_data.path, set_default=project_data.set_default
116+
)
118117

119118
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
120119
message=f"Project '{project_data.name}' added successfully",

src/basic_memory/models/project.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,7 @@ class Project(Base):
4949

5050
# Status flags
5151
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
52-
is_default: Mapped[Optional[bool]] = mapped_column(
53-
Boolean, default=None, unique=True, nullable=True
54-
)
52+
is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
5553

5654
# Timestamps
5755
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

src/basic_memory/services/project_service.py

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,13 @@ async def get_project(self, name: str) -> Optional[Project]:
6767
"""Get the file path for a project by name."""
6868
return await self.repository.get_by_name(name)
6969

70-
async def add_project(self, name: str, path: str) -> None:
70+
async def add_project(self, name: str, path: str, set_default: bool = False) -> None:
7171
"""Add a new project to the configuration and database.
7272
7373
Args:
7474
name: The name of the project
7575
path: The file path to the project directory
76+
set_default: Whether to set this project as the default
7677
7778
Raises:
7879
ValueError: If the project already exists
@@ -92,9 +93,16 @@ async def add_project(self, name: str, path: str) -> None:
9293
"path": resolved_path,
9394
"permalink": generate_permalink(project_config.name),
9495
"is_active": True,
95-
"is_default": False,
96+
# Don't set is_default=False to avoid UNIQUE constraint issues
97+
# Let it default to NULL, only set to True when explicitly making default
9698
}
97-
await self.repository.create(project_data)
99+
created_project = await self.repository.create(project_data)
100+
101+
# If this should be the default project, ensure only one default exists
102+
if set_default:
103+
await self.repository.set_as_default(created_project.id)
104+
config_manager.set_default_project(name)
105+
logger.info(f"Project '{name}' set as default")
98106

99107
logger.info(f"Project '{name}' added at {resolved_path}")
100108

@@ -144,6 +152,45 @@ async def set_default_project(self, name: str) -> None:
144152

145153
logger.info(f"Project '{name}' set as default in configuration and database")
146154

155+
async def _ensure_single_default_project(self) -> None:
156+
"""Ensure only one project has is_default=True.
157+
158+
This method validates the database state and fixes any issues where
159+
multiple projects might have is_default=True or no project is marked as default.
160+
"""
161+
if not self.repository:
162+
raise ValueError("Repository is required for _ensure_single_default_project") # pragma: no cover
163+
164+
# Get all projects with is_default=True
165+
db_projects = await self.repository.find_all()
166+
default_projects = [p for p in db_projects if p.is_default is True]
167+
168+
if len(default_projects) > 1: # pragma: no cover
169+
# Multiple defaults found - fix by keeping the first one and clearing others
170+
# This is defensive code that should rarely execute due to business logic enforcement
171+
logger.warning( # pragma: no cover
172+
f"Found {len(default_projects)} projects with is_default=True, fixing..."
173+
)
174+
keep_default = default_projects[0] # pragma: no cover
175+
176+
# Clear all defaults first, then set only the first one as default
177+
await self.repository.set_as_default(keep_default.id) # pragma: no cover
178+
179+
logger.info(
180+
f"Fixed default project conflicts, kept '{keep_default.name}' as default"
181+
) # pragma: no cover
182+
183+
elif len(default_projects) == 0: # pragma: no cover
184+
# No default project - set the config default as default
185+
# This is defensive code for edge cases where no default exists
186+
config_default = config_manager.default_project # pragma: no cover
187+
config_project = await self.repository.get_by_name(config_default) # pragma: no cover
188+
if config_project: # pragma: no cover
189+
await self.repository.set_as_default(config_project.id) # pragma: no cover
190+
logger.info(
191+
f"Set '{config_default}' as default project (was missing)"
192+
) # pragma: no cover
193+
147194
async def synchronize_projects(self) -> None: # pragma: no cover
148195
"""Synchronize projects between database and configuration.
149196
@@ -172,7 +219,7 @@ async def synchronize_projects(self) -> None: # pragma: no cover
172219
"path": path,
173220
"permalink": name.lower().replace(" ", "-"),
174221
"is_active": True,
175-
"is_default": (name == config_manager.default_project),
222+
# Don't set is_default here - let the enforcement logic handle it
176223
}
177224
await self.repository.create(project_data)
178225

@@ -182,19 +229,23 @@ async def synchronize_projects(self) -> None: # pragma: no cover
182229
logger.info(f"Adding project '{name}' to configuration")
183230
config_manager.add_project(name, project.path)
184231

185-
# Make sure default project is synchronized
186-
db_default = next((p for p in db_projects if p.is_default), None)
232+
# Ensure database default project state is consistent
233+
await self._ensure_single_default_project()
234+
235+
# Make sure default project is synchronized between config and database
236+
db_default = await self.repository.get_default_project()
187237
config_default = config_manager.default_project
188238

189239
if db_default and db_default.name != config_default:
190240
# Update config to match DB default
191241
logger.info(f"Updating default project in config to '{db_default.name}'")
192242
config_manager.set_default_project(db_default.name)
193-
elif not db_default and config_default in db_projects_by_name:
194-
# Update DB to match config default
195-
logger.info(f"Updating default project in database to '{config_default}'")
196-
project = db_projects_by_name[config_default]
197-
await self.repository.set_as_default(project.id)
243+
elif not db_default and config_default:
244+
# Update DB to match config default (if the project exists)
245+
project = await self.repository.get_by_name(config_default)
246+
if project:
247+
logger.info(f"Updating default project in database to '{config_default}'")
248+
await self.repository.set_as_default(project.id)
198249

199250
logger.info("Project synchronization complete")
200251

@@ -546,4 +597,4 @@ def get_system_status(self) -> SystemStatus:
546597
database_size=db_size_readable,
547598
watch_status=watch_status,
548599
timestamp=datetime.now(),
549-
)
600+
)

0 commit comments

Comments
 (0)