feat: add stable external_id (UUID) to Project and Entity models (#485) · basicmachines-co/basic-memory@a4000f6 · GitHub
Skip to content

Commit a4000f6

Browse files
jope-bmclaudephernandez
authored
feat: add stable external_id (UUID) to Project and Entity models (#485)
Signed-off-by: Joe P <joe@basicmemory.com> Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com> Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com> Co-authored-by: phernandez <paul@basicmachines.co>
1 parent 88a1778 commit a4000f6

40 files changed

Lines changed: 1062 additions & 465 deletions
Lines changed: 26 additions & 0 deletions
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Add external_id UUID column to project and entity tables
2+
3+
Revision ID: g9a0b3c4d5e6
4+
Revises: f8a9b2c3d4e5
5+
Create Date: 2025-12-29 10:00:00.000000
6+
7+
"""
8+
9+
import uuid
10+
from typing import Sequence, Union
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
from sqlalchemy import text
15+
16+
17+
def column_exists(connection, table: str, column: str) -> bool:
18+
"""Check if a column exists in a table (idempotent migration support)."""
19+
if connection.dialect.name == "postgresql":
20+
result = connection.execute(
21+
text(
22+
"SELECT 1 FROM information_schema.columns "
23+
"WHERE table_name = :table AND column_name = :column"
24+
),
25+
{"table": table, "column": column},
26+
)
27+
return result.fetchone() is not None
28+
else:
29+
# SQLite
30+
result = connection.execute(text(f"PRAGMA table_info({table})"))
31+
columns = [row[1] for row in result]
32+
return column in columns
33+
34+
35+
def index_exists(connection, index_name: str) -> bool:
36+
"""Check if an index exists (idempotent migration support)."""
37+
if connection.dialect.name == "postgresql":
38+
result = connection.execute(
39+
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
40+
{"index_name": index_name},
41+
)
42+
return result.fetchone() is not None
43+
else:
44+
# SQLite
45+
result = connection.execute(
46+
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
47+
{"index_name": index_name},
48+
)
49+
return result.fetchone() is not None
50+
51+
52+
# revision identifiers, used by Alembic.
53+
revision: str = "g9a0b3c4d5e6"
54+
down_revision: Union[str, None] = "f8a9b2c3d4e5"
55+
branch_labels: Union[str, Sequence[str], None] = None
56+
depends_on: Union[str, Sequence[str], None] = None
57+
58+
59+
def upgrade() -> None:
60+
"""Add external_id UUID column to project and entity tables.
61+
62+
This migration:
63+
1. Adds external_id column to project table
64+
2. Adds external_id column to entity table
65+
3. Generates UUIDs for existing rows
66+
4. Creates unique indexes on both columns
67+
"""
68+
connection = op.get_bind()
69+
dialect = connection.dialect.name
70+
71+
# -------------------------------------------------------------------------
72+
# Add external_id to project table
73+
# -------------------------------------------------------------------------
74+
75+
if not column_exists(connection, "project", "external_id"):
76+
# Step 1: Add external_id column as nullable first
77+
op.add_column("project", sa.Column("external_id", sa.String(), nullable=True))
78+
79+
# Step 2: Generate UUIDs for existing rows
80+
if dialect == "postgresql":
81+
# Postgres has gen_random_uuid() function
82+
op.execute("""
83+
UPDATE project
84+
SET external_id = gen_random_uuid()::text
85+
WHERE external_id IS NULL
86+
""")
87+
else:
88+
# SQLite: need to generate UUIDs in Python
89+
result = connection.execute(text("SELECT id FROM project WHERE external_id IS NULL"))
90+
for row in result:
91+
new_uuid = str(uuid.uuid4())
92+
connection.execute(
93+
text("UPDATE project SET external_id = :uuid WHERE id = :id"),
94+
{"uuid": new_uuid, "id": row[0]},
95+
)
96+
97+
# Step 3: Make external_id NOT NULL
98+
if dialect == "postgresql":
99+
op.alter_column("project", "external_id", nullable=False)
100+
else:
101+
# SQLite requires batch operations for ALTER COLUMN
102+
with op.batch_alter_table("project") as batch_op:
103+
batch_op.alter_column("external_id", nullable=False)
104+
105+
# Step 4: Create unique index on project.external_id (idempotent)
106+
if not index_exists(connection, "ix_project_external_id"):
107+
op.create_index("ix_project_external_id", "project", ["external_id"], unique=True)
108+
109+
# -------------------------------------------------------------------------
110+
# Add external_id to entity table
111+
# -------------------------------------------------------------------------
112+
113+
if not column_exists(connection, "entity", "external_id"):
114+
# Step 1: Add external_id column as nullable first
115+
op.add_column("entity", sa.Column("external_id", sa.String(), nullable=True))
116+
117+
# Step 2: Generate UUIDs for existing rows
118+
if dialect == "postgresql":
119+
# Postgres has gen_random_uuid() function
120+
op.execute("""
121+
UPDATE entity
122+
SET external_id = gen_random_uuid()::text
123+
WHERE external_id IS NULL
124+
""")
125+
else:
126+
# SQLite: need to generate UUIDs in Python
127+
result = connection.execute(text("SELECT id FROM entity WHERE external_id IS NULL"))
128+
for row in result:
129+
new_uuid = str(uuid.uuid4())
130+
connection.execute(
131+
text("UPDATE entity SET external_id = :uuid WHERE id = :id"),
132+
{"uuid": new_uuid, "id": row[0]},
133+
)
134+
135+
# Step 3: Make external_id NOT NULL
136+
if dialect == "postgresql":
137+
op.alter_column("entity", "external_id", nullable=False)
138+
else:
139+
# SQLite requires batch operations for ALTER COLUMN
140+
with op.batch_alter_table("entity") as batch_op:
141+
batch_op.alter_column("external_id", nullable=False)
142+
143+
# Step 4: Create unique index on entity.external_id (idempotent)
144+
if not index_exists(connection, "ix_entity_external_id"):
145+
op.create_index("ix_entity_external_id", "entity", ["external_id"], unique=True)
146+
147+
148+
def downgrade() -> None:
149+
"""Remove external_id columns from project and entity tables."""
150+
connection = op.get_bind()
151+
dialect = connection.dialect.name
152+
153+
# Drop from entity table
154+
if index_exists(connection, "ix_entity_external_id"):
155+
op.drop_index("ix_entity_external_id", table_name="entity")
156+
157+
if column_exists(connection, "entity", "external_id"):
158+
if dialect == "postgresql":
159+
op.drop_column("entity", "external_id")
160+
else:
161+
with op.batch_alter_table("entity") as batch_op:
162+
batch_op.drop_column("external_id")
163+
164+
# Drop from project table
165+
if index_exists(connection, "ix_project_external_id"):
166+
op.drop_index("ix_project_external_id", table_name="project")
167+
168+
if column_exists(connection, "project", "external_id"):
169+
if dialect == "postgresql":
170+
op.drop_column("project", "external_id")
171+
else:
172+
with op.batch_alter_table("project") as batch_op:
173+
batch_op.drop_column("external_id")

src/basic_memory/api/routers/project_router.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ async def get_project(
5151

5252
return ProjectItem(
5353
id=found_project.id,
54+
external_id=found_project.external_id,
5455
name=found_project.name,
5556
path=normalize_project_path(found_project.path),
5657
is_default=found_project.is_default or False,
@@ -89,6 +90,7 @@ async def update_project(
8990

9091
old_project_info = ProjectItem(
9192
id=old_project.id,
93+
external_id=old_project.external_id,
9294
name=old_project.name,
9395
path=old_project.path,
9496
is_default=old_project.is_default or False,
@@ -111,6 +113,7 @@ async def update_project(
111113
old_project=old_project_info,
112114
new_project=ProjectItem(
113115
id=updated_project.id,
116+
external_id=updated_project.external_id,
114117
name=updated_project.name,
115118
path=updated_project.path,
116119
is_default=updated_project.is_default or False,
@@ -203,6 +206,7 @@ async def list_projects(
203206
project_items = [
204207
ProjectItem(
205208
id=project.id,
209+
external_id=project.external_id,
206210
name=project.name,
207211
path=normalize_project_path(project.path),
208212
is_default=project.is_default or False,
@@ -250,6 +254,7 @@ async def add_project(
250254
default=existing_project.is_default or False,
251255
new_project=ProjectItem(
252256
id=existing_project.id,
257+
external_id=existing_project.external_id,
253258
name=existing_project.name,
254259
path=existing_project.path,
255260
is_default=existing_project.is_default or False,
@@ -279,6 +284,7 @@ async def add_project(
279284
default=project_data.set_default,
280285
new_project=ProjectItem(
281286
id=new_project.id,
287+
external_id=new_project.external_id,
282288
name=new_project.name,
283289
path=new_project.path,
284290
is_default=new_project.is_default or False,
@@ -334,6 +340,7 @@ async def remove_project(
334340
default=False,
335341
old_project=ProjectItem(
336342
id=old_project.id,
343+
external_id=old_project.external_id,
337344
name=old_project.name,
338345
path=old_project.path,
339346
is_default=old_project.is_default or False,
@@ -382,12 +389,14 @@ async def set_default_project(
382389
default=True,
383390
old_project=ProjectItem(
384391
id=default_project.id,
392+
external_id=default_project.external_id,
385393
name=default_name,
386394
path=default_project.path,
387395
is_default=False,
388396
),
389397
new_project=ProjectItem(
390398
id=new_default_project.id,
399+
external_id=new_default_project.external_id,
391400
name=name,
392401
path=new_default_project.path,
393402
is_default=True,
@@ -417,6 +426,7 @@ async def get_default_project(
417426

418427
return ProjectItem(
419428
id=default_project.id,
429+
external_id=default_project.external_id,
420430
name=default_project.name,
421431
path=default_project.path,
422432
is_default=True,

src/basic_memory/api/v2/routers/directory_router.py

Lines changed: 13 additions & 13 deletions

0 commit comments

Comments
 (0)