RamshaAnwar commited on
Commit
3e36c5e
·
unverified ·
1 Parent(s): 6926b92

feat(db): add sqlalchemy models, pgvector, and alembic migrations (#1)

Browse files

* chore(db): add docker-compose and config for local postgres

* feat(db): add sqlalchemy engine and session management

* chore(db): configure alembic to read env variables

* feat(db): add initial schema migration with pgvector extension

alembic.ini ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts.
5
+ # this is typically a path given in POSIX (e.g. forward slashes)
6
+ # format, relative to the token %(here)s which refers to the location of this
7
+ # ini file
8
+ script_location = %(here)s/alembic
9
+
10
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
11
+ # Uncomment the line below if you want the files to be prepended with date and time
12
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
13
+ # for all available tokens
14
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
15
+ # Or organize into date-based subdirectories (requires recursive_version_locations = true)
16
+ # file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
17
+
18
+ # sys.path path, will be prepended to sys.path if present.
19
+ # defaults to the current working directory. for multiple paths, the path separator
20
+ # is defined by "path_separator" below.
21
+ prepend_sys_path = .
22
+
23
+
24
+ # timezone to use when rendering the date within the migration file
25
+ # as well as the filename.
26
+ # If specified, requires the tzdata library which can be installed by adding
27
+ # `alembic[tz]` to the pip requirements.
28
+ # string value is passed to ZoneInfo()
29
+ # leave blank for localtime
30
+ # timezone =
31
+
32
+ # max length of characters to apply to the "slug" field
33
+ # truncate_slug_length = 40
34
+
35
+ # set to 'true' to run the environment during
36
+ # the 'revision' command, regardless of autogenerate
37
+ # revision_environment = false
38
+
39
+ # set to 'true' to allow .pyc and .pyo files without
40
+ # a source .py file to be detected as revisions in the
41
+ # versions/ directory
42
+ # sourceless = false
43
+
44
+ # version location specification; This defaults
45
+ # to <script_location>/versions. When using multiple version
46
+ # directories, initial revisions must be specified with --version-path.
47
+ # The path separator used here should be the separator specified by "path_separator"
48
+ # below.
49
+ # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
50
+
51
+ # path_separator; This indicates what character is used to split lists of file
52
+ # paths, including version_locations and prepend_sys_path within configparser
53
+ # files such as alembic.ini.
54
+ # The default rendered in new alembic.ini files is "os", which uses os.pathsep
55
+ # to provide os-dependent path splitting.
56
+ #
57
+ # Note that in order to support legacy alembic.ini files, this default does NOT
58
+ # take place if path_separator is not present in alembic.ini. If this
59
+ # option is omitted entirely, fallback logic is as follows:
60
+ #
61
+ # 1. Parsing of the version_locations option falls back to using the legacy
62
+ # "version_path_separator" key, which if absent then falls back to the legacy
63
+ # behavior of splitting on spaces and/or commas.
64
+ # 2. Parsing of the prepend_sys_path option falls back to the legacy
65
+ # behavior of splitting on spaces, commas, or colons.
66
+ #
67
+ # Valid values for path_separator are:
68
+ #
69
+ # path_separator = :
70
+ # path_separator = ;
71
+ # path_separator = space
72
+ # path_separator = newline
73
+ #
74
+ # Use os.pathsep. Default configuration used for new projects.
75
+ path_separator = os
76
+
77
+ # set to 'true' to search source files recursively
78
+ # in each "version_locations" directory
79
+ # new in Alembic version 1.10
80
+ # recursive_version_locations = false
81
+
82
+ # the output encoding used when revision files
83
+ # are written from script.py.mako
84
+ # output_encoding = utf-8
85
+
86
+ # database URL. This is consumed by the user-maintained env.py script only.
87
+ # other means of configuring database URLs may be customized within the env.py
88
+ # file.
89
+ sqlalchemy.url =
90
+
91
+
92
+ [post_write_hooks]
93
+ # post_write_hooks defines scripts or Python functions that are run
94
+ # on newly generated revision scripts. See the documentation for further
95
+ # detail and examples
96
+
97
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
98
+ # hooks = black
99
+ # black.type = console_scripts
100
+ # black.entrypoint = black
101
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
102
+
103
+ # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
104
+ # hooks = ruff
105
+ # ruff.type = module
106
+ # ruff.module = ruff
107
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
108
+
109
+ # Alternatively, use the exec runner to execute a binary found on your PATH
110
+ # hooks = ruff
111
+ # ruff.type = exec
112
+ # ruff.executable = ruff
113
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
114
+
115
+ # Logging configuration. This is also consumed by the user-maintained
116
+ # env.py script only.
117
+ [loggers]
118
+ keys = root,sqlalchemy,alembic
119
+
120
+ [handlers]
121
+ keys = console
122
+
123
+ [formatters]
124
+ keys = generic
125
+
126
+ [logger_root]
127
+ level = WARNING
128
+ handlers = console
129
+ qualname =
130
+
131
+ [logger_sqlalchemy]
132
+ level = WARNING
133
+ handlers =
134
+ qualname = sqlalchemy.engine
135
+
136
+ [logger_alembic]
137
+ level = INFO
138
+ handlers =
139
+ qualname = alembic
140
+
141
+ [handler_console]
142
+ class = StreamHandler
143
+ args = (sys.stderr,)
144
+ level = NOTSET
145
+ formatter = generic
146
+
147
+ [formatter_generic]
148
+ format = %(levelname)-5.5s [%(name)s] %(message)s
149
+ datefmt = %H:%M:%S
alembic/env.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from logging.config import fileConfig
2
+ from sqlalchemy import engine_from_config
3
+ from sqlalchemy import pool
4
+ from alembic import context
5
+ from app.config import settings
6
+ from app.db import Base
7
+ from app import models
8
+
9
+ config = context.config
10
+
11
+ if config.config_file_name is not None:
12
+ fileConfig(config.config_file_name)
13
+
14
+ target_metadata = Base.metadata
15
+
16
+ def run_migrations_offline() -> None:
17
+ url = settings.DIRECT_DATABASE_URL or settings.DATABASE_URL
18
+ context.configure(
19
+ url=url,
20
+ target_metadata=target_metadata,
21
+ literal_binds=True,
22
+ dialect_opts={"paramstyle": "named"},
23
+ )
24
+ with context.begin_transaction():
25
+ context.run_migrations()
26
+
27
+ def run_migrations_online() -> None:
28
+ configuration = config.get_section(config.config_ini_section, {})
29
+ configuration["sqlalchemy.url"] = settings.DIRECT_DATABASE_URL or settings.DATABASE_URL
30
+
31
+ connectable = engine_from_config(
32
+ configuration,
33
+ prefix="sqlalchemy.",
34
+ poolclass=pool.NullPool,
35
+ )
36
+ with connectable.connect() as connection:
37
+ context.configure(
38
+ connection=connection, target_metadata=target_metadata
39
+ )
40
+ with context.begin_transaction():
41
+ context.run_migrations()
42
+
43
+ if context.is_offline_mode():
44
+ run_migrations_offline()
45
+ else:
46
+ run_migrations_online()
alembic/versions/04b68bac8d84_initial_schema_with_pgvector.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """initial schema with pgvector
2
+
3
+ Revision ID: 04b68bac8d84
4
+ Revises:
5
+ Create Date: 2026-10-18 14:52:32.088121
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ from sqlalchemy.dialects import postgresql
13
+ import pgvector.sqlalchemy
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = '04b68bac8d84'
17
+ down_revision: Union[str, Sequence[str], None] = None
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
+ """Upgrade schema."""
24
+ # ### commands auto generated by Alembic - please adjust! ###
25
+ op.execute('CREATE EXTENSION IF NOT EXISTS vector;')
26
+ op.create_table('users',
27
+ sa.Column('id', sa.UUID(), nullable=False),
28
+ sa.Column('email', sa.String(length=320), nullable=False),
29
+ sa.Column('password_hash', sa.String(length=255), nullable=False),
30
+ sa.Column('role', sa.String(length=20), nullable=False),
31
+ sa.Column('is_active', sa.Boolean(), nullable=False),
32
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
33
+ sa.PrimaryKeyConstraint('id')
34
+ )
35
+ op.create_table('hubs',
36
+ sa.Column('id', sa.UUID(), nullable=False),
37
+ sa.Column('owner_id', sa.UUID(), nullable=False),
38
+ sa.Column('name', sa.String(length=120), nullable=False),
39
+ sa.Column('description', sa.String(length=500), nullable=True),
40
+ sa.Column('ai_summary', sa.String(), nullable=True),
41
+ sa.Column('suggested_questions', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
42
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
43
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
44
+ sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
45
+ sa.PrimaryKeyConstraint('id'),
46
+ sa.UniqueConstraint('owner_id', 'name', name='uq_hub_name_per_owner')
47
+ )
48
+ op.create_table('idempotency_keys',
49
+ sa.Column('user_id', sa.UUID(), nullable=False),
50
+ sa.Column('key', sa.UUID(), nullable=False),
51
+ sa.Column('request_method', sa.String(length=10), nullable=False),
52
+ sa.Column('request_path', sa.String(length=255), nullable=False),
53
+ sa.Column('request_hash', sa.String(length=64), nullable=False),
54
+ sa.Column('status', sa.String(length=20), nullable=False),
55
+ sa.Column('response_status', sa.Integer(), nullable=True),
56
+ sa.Column('response_body', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
57
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
58
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
59
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
60
+ sa.PrimaryKeyConstraint('user_id', 'key')
61
+ )
62
+ op.create_table('documents',
63
+ sa.Column('id', sa.UUID(), nullable=False),
64
+ sa.Column('hub_id', sa.UUID(), nullable=False),
65
+ sa.Column('owner_id', sa.UUID(), nullable=False),
66
+ sa.Column('origin', sa.String(length=20), nullable=False),
67
+ sa.Column('filename', sa.String(length=255), nullable=False),
68
+ sa.Column('mime_type', sa.String(length=100), nullable=False),
69
+ sa.Column('size_bytes', sa.Integer(), nullable=False),
70
+ sa.Column('checksum_sha256', sa.String(length=64), nullable=False),
71
+ sa.Column('status', sa.String(length=20), nullable=False),
72
+ sa.Column('error_message', sa.String(), nullable=True),
73
+ sa.Column('file_bytes', sa.LargeBinary(), nullable=True),
74
+ sa.Column('process_claim', sa.UUID(), nullable=True),
75
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
76
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
77
+ sa.ForeignKeyConstraint(['hub_id'], ['hubs.id'], ondelete='CASCADE'),
78
+ sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
79
+ sa.PrimaryKeyConstraint('id'),
80
+ sa.UniqueConstraint('hub_id', 'checksum_sha256', name='uq_doc_checksum_per_hub')
81
+ )
82
+ op.create_table('chunks',
83
+ sa.Column('id', sa.UUID(), nullable=False),
84
+ sa.Column('document_id', sa.UUID(), nullable=False),
85
+ sa.Column('hub_id', sa.UUID(), nullable=False),
86
+ sa.Column('owner_id', sa.UUID(), nullable=False),
87
+ sa.Column('chunk_index', sa.Integer(), nullable=False),
88
+ sa.Column('text', sa.String(), nullable=False),
89
+ sa.Column('token_count', sa.Integer(), nullable=False),
90
+ sa.Column('embedding', pgvector.sqlalchemy.vector.VECTOR(dim=768), nullable=False),
91
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
92
+ sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
93
+ sa.ForeignKeyConstraint(['hub_id'], ['hubs.id'], ondelete='CASCADE'),
94
+ sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
95
+ sa.PrimaryKeyConstraint('id')
96
+ )
97
+ op.create_index('uq_users_email_lower', 'users', [sa.text('lower(email)')], unique=True)
98
+ # ### end Alembic commands ###
99
+
100
+
101
+ def downgrade() -> None:
102
+ """Downgrade schema."""
103
+ # ### commands auto generated by Alembic - please adjust! ###
104
+ op.drop_table('chunks')
105
+ op.drop_table('documents')
106
+ op.drop_table('idempotency_keys')
107
+ op.drop_table('hubs')
108
+ op.drop_table('users')
109
+ # ### end Alembic commands ###
app/config.py CHANGED
@@ -5,7 +5,8 @@ class Settings(BaseSettings):
5
  model_config = SettingsConfigDict(env_file=".env", extra="ignore")
6
 
7
  # Secrets — no defaults. Missing -> startup fails, naming the variable.
8
- DATABASE_URL: str
 
9
  JWT_SECRET_KEY: str
10
  GEMINI_API_KEY: str
11
 
@@ -28,5 +29,4 @@ class Settings(BaseSettings):
28
  return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
29
 
30
 
31
- settings = Settings()
32
-
 
5
  model_config = SettingsConfigDict(env_file=".env", extra="ignore")
6
 
7
  # Secrets — no defaults. Missing -> startup fails, naming the variable.
8
+ DATABASE_URL: str
9
+ DIRECT_DATABASE_URL: str | None = None
10
  JWT_SECRET_KEY: str
11
  GEMINI_API_KEY: str
12
 
 
29
  return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
30
 
31
 
32
+ settings = Settings()
 
app/db.py CHANGED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.orm import sessionmaker, declarative_base
3
+ from app.config import settings
4
+
5
+ engine = create_engine(
6
+ settings.DATABASE_URL,
7
+ pool_pre_ping=True,
8
+ pool_size=3,
9
+ max_overflow=2,
10
+ connect_args={"prepare_threshold": None}
11
+ )
12
+
13
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
14
+
15
+ Base = declarative_base()
16
+
17
+ def get_db():
18
+ db = SessionLocal()
19
+ try:
20
+ yield db
21
+ finally:
22
+ db.close()
docker-compose.yml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ db:
3
+ image: pgvector/pgvector:pg16
4
+ container_name: contextquery_db_final
5
+ environment:
6
+ POSTGRES_USER: cq
7
+ POSTGRES_PASSWORD: cq
8
+ POSTGRES_DB: contextquery
9
+ ports:
10
+ - "55432:5432"
11
+ volumes:
12
+ - pgdata_final:/var/lib/postgresql/data
13
+
14
+ volumes:
15
+ pgdata_final: