kollabor 0.4.9__py3-none-any.whl → 0.4.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +2 -0
- agents/coder/__init__.py +0 -0
- agents/coder/agent.json +4 -0
- agents/coder/api-integration.md +2150 -0
- agents/coder/cli-pretty.md +765 -0
- agents/coder/code-review.md +1092 -0
- agents/coder/database-design.md +1525 -0
- agents/coder/debugging.md +1102 -0
- agents/coder/dependency-management.md +1397 -0
- agents/coder/git-workflow.md +1099 -0
- agents/coder/refactoring.md +1454 -0
- agents/coder/security-hardening.md +1732 -0
- agents/coder/system_prompt.md +1448 -0
- agents/coder/tdd.md +1367 -0
- agents/creative-writer/__init__.py +0 -0
- agents/creative-writer/agent.json +4 -0
- agents/creative-writer/character-development.md +1852 -0
- agents/creative-writer/dialogue-craft.md +1122 -0
- agents/creative-writer/plot-structure.md +1073 -0
- agents/creative-writer/revision-editing.md +1484 -0
- agents/creative-writer/system_prompt.md +690 -0
- agents/creative-writer/worldbuilding.md +2049 -0
- agents/data-analyst/__init__.py +30 -0
- agents/data-analyst/agent.json +4 -0
- agents/data-analyst/data-visualization.md +992 -0
- agents/data-analyst/exploratory-data-analysis.md +1110 -0
- agents/data-analyst/pandas-data-manipulation.md +1081 -0
- agents/data-analyst/sql-query-optimization.md +881 -0
- agents/data-analyst/statistical-analysis.md +1118 -0
- agents/data-analyst/system_prompt.md +928 -0
- agents/default/__init__.py +0 -0
- agents/default/agent.json +4 -0
- agents/default/dead-code.md +794 -0
- agents/default/explore-agent-system.md +585 -0
- agents/default/system_prompt.md +1448 -0
- agents/kollabor/__init__.py +0 -0
- agents/kollabor/analyze-plugin-lifecycle.md +175 -0
- agents/kollabor/analyze-terminal-rendering.md +388 -0
- agents/kollabor/code-review.md +1092 -0
- agents/kollabor/debug-mcp-integration.md +521 -0
- agents/kollabor/debug-plugin-hooks.md +547 -0
- agents/kollabor/debugging.md +1102 -0
- agents/kollabor/dependency-management.md +1397 -0
- agents/kollabor/git-workflow.md +1099 -0
- agents/kollabor/inspect-llm-conversation.md +148 -0
- agents/kollabor/monitor-event-bus.md +558 -0
- agents/kollabor/profile-performance.md +576 -0
- agents/kollabor/refactoring.md +1454 -0
- agents/kollabor/system_prompt copy.md +1448 -0
- agents/kollabor/system_prompt.md +757 -0
- agents/kollabor/trace-command-execution.md +178 -0
- agents/kollabor/validate-config.md +879 -0
- agents/research/__init__.py +0 -0
- agents/research/agent.json +4 -0
- agents/research/architecture-mapping.md +1099 -0
- agents/research/codebase-analysis.md +1077 -0
- agents/research/dependency-audit.md +1027 -0
- agents/research/performance-profiling.md +1047 -0
- agents/research/security-review.md +1359 -0
- agents/research/system_prompt.md +492 -0
- agents/technical-writer/__init__.py +0 -0
- agents/technical-writer/agent.json +4 -0
- agents/technical-writer/api-documentation.md +2328 -0
- agents/technical-writer/changelog-management.md +1181 -0
- agents/technical-writer/readme-writing.md +1360 -0
- agents/technical-writer/style-guide.md +1410 -0
- agents/technical-writer/system_prompt.md +653 -0
- agents/technical-writer/tutorial-creation.md +1448 -0
- core/__init__.py +0 -2
- core/application.py +343 -88
- core/cli.py +229 -10
- core/commands/menu_renderer.py +463 -59
- core/commands/registry.py +14 -9
- core/commands/system_commands.py +2461 -14
- core/config/loader.py +151 -37
- core/config/service.py +18 -6
- core/events/bus.py +29 -9
- core/events/executor.py +205 -75
- core/events/models.py +27 -8
- core/fullscreen/command_integration.py +20 -24
- core/fullscreen/components/__init__.py +10 -1
- core/fullscreen/components/matrix_components.py +1 -2
- core/fullscreen/components/space_shooter_components.py +654 -0
- core/fullscreen/plugin.py +5 -0
- core/fullscreen/renderer.py +52 -13
- core/fullscreen/session.py +52 -15
- core/io/__init__.py +29 -5
- core/io/buffer_manager.py +6 -1
- core/io/config_status_view.py +7 -29
- core/io/core_status_views.py +267 -347
- core/io/input/__init__.py +25 -0
- core/io/input/command_mode_handler.py +711 -0
- core/io/input/display_controller.py +128 -0
- core/io/input/hook_registrar.py +286 -0
- core/io/input/input_loop_manager.py +421 -0
- core/io/input/key_press_handler.py +502 -0
- core/io/input/modal_controller.py +1011 -0
- core/io/input/paste_processor.py +339 -0
- core/io/input/status_modal_renderer.py +184 -0
- core/io/input_errors.py +5 -1
- core/io/input_handler.py +211 -2452
- core/io/key_parser.py +7 -0
- core/io/layout.py +15 -3
- core/io/message_coordinator.py +111 -2
- core/io/message_renderer.py +129 -4
- core/io/status_renderer.py +147 -607
- core/io/terminal_renderer.py +97 -51
- core/io/terminal_state.py +21 -4
- core/io/visual_effects.py +816 -165
- core/llm/agent_manager.py +1063 -0
- core/llm/api_adapters/__init__.py +44 -0
- core/llm/api_adapters/anthropic_adapter.py +432 -0
- core/llm/api_adapters/base.py +241 -0
- core/llm/api_adapters/openai_adapter.py +326 -0
- core/llm/api_communication_service.py +167 -113
- core/llm/conversation_logger.py +322 -16
- core/llm/conversation_manager.py +556 -30
- core/llm/file_operations_executor.py +84 -32
- core/llm/llm_service.py +934 -103
- core/llm/mcp_integration.py +541 -57
- core/llm/message_display_service.py +135 -18
- core/llm/plugin_sdk.py +1 -2
- core/llm/profile_manager.py +1183 -0
- core/llm/response_parser.py +274 -56
- core/llm/response_processor.py +16 -3
- core/llm/tool_executor.py +6 -1
- core/logging/__init__.py +2 -0
- core/logging/setup.py +34 -6
- core/models/resume.py +54 -0
- core/plugins/__init__.py +4 -2
- core/plugins/base.py +127 -0
- core/plugins/collector.py +23 -161
- core/plugins/discovery.py +37 -3
- core/plugins/factory.py +6 -12
- core/plugins/registry.py +5 -17
- core/ui/config_widgets.py +128 -28
- core/ui/live_modal_renderer.py +2 -1
- core/ui/modal_actions.py +5 -0
- core/ui/modal_overlay_renderer.py +0 -60
- core/ui/modal_renderer.py +268 -7
- core/ui/modal_state_manager.py +29 -4
- core/ui/widgets/base_widget.py +7 -0
- core/updates/__init__.py +10 -0
- core/updates/version_check_service.py +348 -0
- core/updates/version_comparator.py +103 -0
- core/utils/config_utils.py +685 -526
- core/utils/plugin_utils.py +1 -1
- core/utils/session_naming.py +111 -0
- fonts/LICENSE +21 -0
- fonts/README.md +46 -0
- fonts/SymbolsNerdFont-Regular.ttf +0 -0
- fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
- fonts/__init__.py +44 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
- kollabor-0.4.15.dist-info/RECORD +228 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
- plugins/agent_orchestrator/__init__.py +39 -0
- plugins/agent_orchestrator/activity_monitor.py +181 -0
- plugins/agent_orchestrator/file_attacher.py +77 -0
- plugins/agent_orchestrator/message_injector.py +135 -0
- plugins/agent_orchestrator/models.py +48 -0
- plugins/agent_orchestrator/orchestrator.py +403 -0
- plugins/agent_orchestrator/plugin.py +976 -0
- plugins/agent_orchestrator/xml_parser.py +191 -0
- plugins/agent_orchestrator_plugin.py +9 -0
- plugins/enhanced_input/box_styles.py +1 -0
- plugins/enhanced_input/color_engine.py +19 -4
- plugins/enhanced_input/config.py +2 -2
- plugins/enhanced_input_plugin.py +61 -11
- plugins/fullscreen/__init__.py +6 -2
- plugins/fullscreen/example_plugin.py +1035 -222
- plugins/fullscreen/setup_wizard_plugin.py +592 -0
- plugins/fullscreen/space_shooter_plugin.py +131 -0
- plugins/hook_monitoring_plugin.py +436 -78
- plugins/query_enhancer_plugin.py +66 -30
- plugins/resume_conversation_plugin.py +1494 -0
- plugins/save_conversation_plugin.py +98 -32
- plugins/system_commands_plugin.py +70 -56
- plugins/tmux_plugin.py +154 -78
- plugins/workflow_enforcement_plugin.py +94 -92
- system_prompt/default.md +952 -886
- core/io/input_mode_manager.py +0 -402
- core/io/modal_interaction_handler.py +0 -315
- core/io/raw_input_processor.py +0 -946
- core/storage/__init__.py +0 -5
- core/storage/state_manager.py +0 -84
- core/ui/widget_integration.py +0 -222
- core/utils/key_reader.py +0 -171
- kollabor-0.4.9.dist-info/RECORD +0 -128
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1525 @@
|
|
|
1
|
+
<!-- Database Design skill - schema design and migrations -->
|
|
2
|
+
|
|
3
|
+
database-design mode: DATA PERSISTENCE DONE RIGHT
|
|
4
|
+
|
|
5
|
+
when this skill is active, you follow disciplined database design practices.
|
|
6
|
+
this is a comprehensive guide to schema design and database migrations.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PHASE 0: ENVIRONMENT PREREQUISITES VERIFICATION
|
|
10
|
+
|
|
11
|
+
before designing ANY schema, verify your database environment is ready.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
check database client
|
|
15
|
+
|
|
16
|
+
<terminal>psql --version 2>/dev/null || echo "postgresql client not installed"</terminal>
|
|
17
|
+
|
|
18
|
+
<terminal>sqlite3 --version 2>/dev/null || echo "sqlite3 not installed"</terminal>
|
|
19
|
+
|
|
20
|
+
<terminal>mysql --version 2>/dev/null || echo "mysql client not installed"</terminal>
|
|
21
|
+
|
|
22
|
+
install based on your database:
|
|
23
|
+
<terminal>brew install postgresql</terminal> # macOS
|
|
24
|
+
<terminal>apt-get install postgresql-client</terminal> # ubuntu
|
|
25
|
+
<terminal>pip install psycopg2-binary</terminal> # python driver
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
check orm setup
|
|
29
|
+
|
|
30
|
+
<terminal>python -c "import sqlalchemy; print(sqlalchemy.__version__)"</terminal>
|
|
31
|
+
|
|
32
|
+
if not installed:
|
|
33
|
+
<terminal>pip install sqlalchemy</terminal>
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
check alembic for migrations
|
|
37
|
+
|
|
38
|
+
<terminal>python -c "import alembic; print(alembic.__version__)"</terminal>
|
|
39
|
+
|
|
40
|
+
if not installed:
|
|
41
|
+
<terminal>pip install alembic</terminal>
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
check database connection
|
|
45
|
+
|
|
46
|
+
<terminal>cat .env 2>/dev/null | grep -i database || echo "no database config in .env"</terminal>
|
|
47
|
+
|
|
48
|
+
<terminal>echo $DATABASE_URL 2>/dev/null || echo "DATABASE_URL not set"</terminal>
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
verify existing migrations
|
|
52
|
+
|
|
53
|
+
<terminal>ls -la migrations/ 2>/dev/null || echo "no migrations directory"</terminal>
|
|
54
|
+
|
|
55
|
+
<terminal>ls -la alembic/versions/ 2>/dev/null || echo "no alembic versions"</terminal>
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
check for schema visualization tools
|
|
59
|
+
|
|
60
|
+
<terminal>which dbdiagram.io 2>/dev/null || echo "consider schema viz tools"</terminal>
|
|
61
|
+
|
|
62
|
+
<terminal>pip install eralchemy 2>/dev/null || echo "eralchemy for schema diagrams"</terminal>
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
PHASE 1: DATA MODELING FUNDAMENTALS
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
understand the domain
|
|
69
|
+
|
|
70
|
+
before touching a database, answer these questions:
|
|
71
|
+
|
|
72
|
+
[ ] what are the core entities?
|
|
73
|
+
[ ] what are the relationships between them?
|
|
74
|
+
[ ] what data needs to be persisted?
|
|
75
|
+
[ ] what are the query patterns?
|
|
76
|
+
[ ] what are the data volume estimates?
|
|
77
|
+
[ ] what are the consistency requirements?
|
|
78
|
+
|
|
79
|
+
domain modeling checklist:
|
|
80
|
+
- identify nouns as potential entities
|
|
81
|
+
- identify verbs as relationships
|
|
82
|
+
- identify adjectives as attributes
|
|
83
|
+
- consider the lifecycle of each entity
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
normalize your data
|
|
87
|
+
|
|
88
|
+
normalization eliminates redundancy and prevents anomalies:
|
|
89
|
+
|
|
90
|
+
first normal form (1NF):
|
|
91
|
+
- eliminate repeating groups
|
|
92
|
+
- each cell contains atomic values
|
|
93
|
+
- each record is unique
|
|
94
|
+
|
|
95
|
+
second normal form (2NF):
|
|
96
|
+
- must be in 1NF
|
|
97
|
+
- eliminate partial dependencies
|
|
98
|
+
- all non-key attributes depend on the entire primary key
|
|
99
|
+
|
|
100
|
+
third normal form (3NF):
|
|
101
|
+
- must be in 2NF
|
|
102
|
+
- eliminate transitive dependencies
|
|
103
|
+
- non-key attributes depend only on the primary key
|
|
104
|
+
|
|
105
|
+
boyce-codd normal form (BCNF):
|
|
106
|
+
- stronger version of 3NF
|
|
107
|
+
- every determinant is a candidate key
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
when to denormalize
|
|
111
|
+
|
|
112
|
+
denormalize for:
|
|
113
|
+
- read-heavy workloads
|
|
114
|
+
- reporting and analytics
|
|
115
|
+
- frequently accessed data
|
|
116
|
+
- reducing join complexity
|
|
117
|
+
|
|
118
|
+
denormalize sparingly and deliberately.
|
|
119
|
+
document every denormalization decision.
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
PHASE 2: IDENTIFYING ENTITIES AND ATTRIBUTES
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
entity definition
|
|
126
|
+
|
|
127
|
+
each entity should have:
|
|
128
|
+
- a clear purpose
|
|
129
|
+
- a primary key
|
|
130
|
+
- a set of attributes
|
|
131
|
+
- relationships to other entities
|
|
132
|
+
|
|
133
|
+
<create>
|
|
134
|
+
<file>src/models/user.py</file>
|
|
135
|
+
<content>
|
|
136
|
+
"""User entity model."""
|
|
137
|
+
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
|
138
|
+
from sqlalchemy.orm import declarative_base
|
|
139
|
+
from datetime import datetime
|
|
140
|
+
|
|
141
|
+
Base = declarative_base()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class User(Base):
|
|
145
|
+
"""User entity representing application users."""
|
|
146
|
+
|
|
147
|
+
__tablename__ = "users"
|
|
148
|
+
|
|
149
|
+
# primary key - always required
|
|
150
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
151
|
+
|
|
152
|
+
# required attributes
|
|
153
|
+
email = Column(String(255), nullable=False, unique=True, index=True)
|
|
154
|
+
username = Column(String(50), nullable=False, unique=True, index=True)
|
|
155
|
+
password_hash = Column(String(255), nullable=False)
|
|
156
|
+
|
|
157
|
+
# optional attributes
|
|
158
|
+
full_name = Column(String(100))
|
|
159
|
+
avatar_url = Column(String(500))
|
|
160
|
+
bio = Column(String(500))
|
|
161
|
+
|
|
162
|
+
# status fields
|
|
163
|
+
is_active = Column(Boolean, default=True, nullable=False)
|
|
164
|
+
is_verified = Column(Boolean, default=False, nullable=False)
|
|
165
|
+
|
|
166
|
+
# timestamps
|
|
167
|
+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
168
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
169
|
+
last_login_at = Column(DateTime)
|
|
170
|
+
|
|
171
|
+
def __repr__(self):
|
|
172
|
+
return f"<User(id={self.id}, username={self.username})>"
|
|
173
|
+
</content>
|
|
174
|
+
</create>
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
attribute types guide
|
|
178
|
+
|
|
179
|
+
integers:
|
|
180
|
+
- id fields: Integer or BigInteger
|
|
181
|
+
- counts: Integer, default 0
|
|
182
|
+
- enums: Integer with check constraint
|
|
183
|
+
|
|
184
|
+
strings:
|
|
185
|
+
- short names: String(50-100)
|
|
186
|
+
- emails: String(255), add index
|
|
187
|
+
- urls: String(500-2000)
|
|
188
|
+
- rich text: Text or Text(n)
|
|
189
|
+
|
|
190
|
+
dates:
|
|
191
|
+
- created_at: DateTime, default=datetime.utcnow
|
|
192
|
+
- updated_at: DateTime, onupdate=datetime.utcnow
|
|
193
|
+
- dates only: Date
|
|
194
|
+
|
|
195
|
+
decimals:
|
|
196
|
+
- money: Numeric(10, 2) or Numeric(19, 4)
|
|
197
|
+
- percentages: Numeric(5, 2)
|
|
198
|
+
|
|
199
|
+
booleans:
|
|
200
|
+
- flags: Boolean, default=False
|
|
201
|
+
- nullable booleans for tri-state
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
naming conventions
|
|
205
|
+
|
|
206
|
+
be consistent:
|
|
207
|
+
- table names: plural, snake_case (users, user_profiles)
|
|
208
|
+
- column names: snake_case (created_at, is_active)
|
|
209
|
+
- primary keys: id or {table}_id
|
|
210
|
+
- foreign keys: {related_table}_id (user_id, organization_id)
|
|
211
|
+
- indexes: idx_{table}_{columns} (idx_users_email)
|
|
212
|
+
- unique constraints: uq_{table}_{columns}
|
|
213
|
+
- foreign key constraints: fk_{table}_{ref_table}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
PHASE 3: RELATIONSHIPS AND FOREIGN KEYS
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
one-to-many relationship
|
|
220
|
+
|
|
221
|
+
<create>
|
|
222
|
+
<file>src/models/post.py</file>
|
|
223
|
+
<content>
|
|
224
|
+
"""Post entity with relationships."""
|
|
225
|
+
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
|
|
226
|
+
from sqlalchemy.orm import relationship
|
|
227
|
+
|
|
228
|
+
from .user import Base, User
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class Post(Base):
|
|
232
|
+
"""Blog post authored by a user."""
|
|
233
|
+
|
|
234
|
+
__tablename__ = "posts"
|
|
235
|
+
|
|
236
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
237
|
+
title = Column(String(200), nullable=False)
|
|
238
|
+
slug = Column(String(200), nullable=False, unique=True, index=True)
|
|
239
|
+
content = Column(Text, nullable=False)
|
|
240
|
+
|
|
241
|
+
# foreign key to users (many-to-one)
|
|
242
|
+
author_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
|
243
|
+
|
|
244
|
+
# timestamps
|
|
245
|
+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
246
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
247
|
+
published_at = Column(DateTime)
|
|
248
|
+
|
|
249
|
+
# relationships
|
|
250
|
+
author = relationship("User", back_populates="posts")
|
|
251
|
+
|
|
252
|
+
def __repr__(self):
|
|
253
|
+
return f"<Post(id={self.id}, title={self.title})>"
|
|
254
|
+
</content>
|
|
255
|
+
</create>
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
add back-reference on User
|
|
259
|
+
|
|
260
|
+
<read><file>src/models/user.py</file></read>
|
|
261
|
+
|
|
262
|
+
<edit>
|
|
263
|
+
<file>src/models/user.py</file>
|
|
264
|
+
<content>
|
|
265
|
+
"""User entity model."""
|
|
266
|
+
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
|
267
|
+
from sqlalchemy.orm import declarative_base, relationship
|
|
268
|
+
from datetime import datetime
|
|
269
|
+
|
|
270
|
+
Base = declarative_base()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class User(Base):
|
|
274
|
+
"""User entity representing application users."""
|
|
275
|
+
|
|
276
|
+
__tablename__ = "users"
|
|
277
|
+
|
|
278
|
+
# primary key - always required
|
|
279
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
280
|
+
|
|
281
|
+
# required attributes
|
|
282
|
+
email = Column(String(255), nullable=False, unique=True, index=True)
|
|
283
|
+
username = Column(String(50), nullable=False, unique=True, index=True)
|
|
284
|
+
password_hash = Column(String(255), nullable=False)
|
|
285
|
+
|
|
286
|
+
# optional attributes
|
|
287
|
+
full_name = Column(String(100))
|
|
288
|
+
avatar_url = Column(String(500))
|
|
289
|
+
bio = Column(String(500))
|
|
290
|
+
|
|
291
|
+
# status fields
|
|
292
|
+
is_active = Column(Boolean, default=True, nullable=False)
|
|
293
|
+
is_verified = Column(Boolean, default=False, nullable=False)
|
|
294
|
+
|
|
295
|
+
# timestamps
|
|
296
|
+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
297
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
298
|
+
last_login_at = Column(DateTime)
|
|
299
|
+
|
|
300
|
+
# relationships
|
|
301
|
+
posts = relationship("Post", back_populates="author")
|
|
302
|
+
|
|
303
|
+
def __repr__(self):
|
|
304
|
+
return f"<User(id={self.id}, username={self.username})>"
|
|
305
|
+
</content>
|
|
306
|
+
</edit>
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
many-to-many relationship
|
|
310
|
+
|
|
311
|
+
<create>
|
|
312
|
+
<file>src/models/tag.py</file>
|
|
313
|
+
<content>
|
|
314
|
+
"""Tag entity and many-to-many relationship with posts."""
|
|
315
|
+
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Table
|
|
316
|
+
from sqlalchemy.orm import relationship
|
|
317
|
+
|
|
318
|
+
from .user import Base
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# association table for many-to-many relationship
|
|
322
|
+
post_tags = Table(
|
|
323
|
+
"post_tags",
|
|
324
|
+
Base.metadata,
|
|
325
|
+
Column("post_id", Integer, ForeignKey("posts.id"), primary_key=True),
|
|
326
|
+
Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True),
|
|
327
|
+
Column("assigned_at", DateTime, default=datetime.utcnow)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class Tag(Base):
|
|
332
|
+
"""Tag entity for categorizing posts."""
|
|
333
|
+
|
|
334
|
+
__tablename__ = "tags"
|
|
335
|
+
|
|
336
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
337
|
+
name = Column(String(50), nullable=False, unique=True, index=True)
|
|
338
|
+
slug = Column(String(50), nullable=False, unique=True)
|
|
339
|
+
color = Column(String(7)) # hex color like #ff0000
|
|
340
|
+
|
|
341
|
+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
342
|
+
|
|
343
|
+
# relationships
|
|
344
|
+
posts = relationship("Post", secondary=post_tags, back_populates="tags")
|
|
345
|
+
|
|
346
|
+
def __repr__(self):
|
|
347
|
+
return f"<Tag(id={self.id}, name={self.name})>"
|
|
348
|
+
</content>
|
|
349
|
+
</edit>
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
add tags relationship to Post
|
|
353
|
+
|
|
354
|
+
<read><file>src/models/post.py</file></read>
|
|
355
|
+
|
|
356
|
+
<edit>
|
|
357
|
+
<file>src/models/post.py</file>
|
|
358
|
+
<find>
|
|
359
|
+
"""Post entity with relationships."""
|
|
360
|
+
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
|
|
361
|
+
from sqlalchemy.orm import relationship
|
|
362
|
+
|
|
363
|
+
from .user import Base, User
|
|
364
|
+
</find>
|
|
365
|
+
<replace>
|
|
366
|
+
"""Post entity with relationships."""
|
|
367
|
+
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
|
|
368
|
+
from sqlalchemy.orm import relationship
|
|
369
|
+
|
|
370
|
+
from .user import Base, User
|
|
371
|
+
from .tag import post_tags
|
|
372
|
+
</replace>
|
|
373
|
+
</edit>
|
|
374
|
+
|
|
375
|
+
<edit>
|
|
376
|
+
<file>src/models/post.py</file>
|
|
377
|
+
<find>
|
|
378
|
+
# relationships
|
|
379
|
+
author = relationship("User", back_populates="posts")
|
|
380
|
+
</find>
|
|
381
|
+
<replace>
|
|
382
|
+
# relationships
|
|
383
|
+
author = relationship("User", back_populates="posts")
|
|
384
|
+
tags = relationship("Tag", secondary=post_tags, back_populates="posts")
|
|
385
|
+
</replace>
|
|
386
|
+
</edit>
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
one-to-one relationship
|
|
390
|
+
|
|
391
|
+
<create>
|
|
392
|
+
<file>src/models/profile.py</file>
|
|
393
|
+
<content>
|
|
394
|
+
"""User profile - one-to-one with user."""
|
|
395
|
+
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Date
|
|
396
|
+
from sqlalchemy.orm import relationship
|
|
397
|
+
|
|
398
|
+
from .user import Base
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class UserProfile(Base):
|
|
402
|
+
"""Extended profile information for users."""
|
|
403
|
+
|
|
404
|
+
__tablename__ = "user_profiles"
|
|
405
|
+
|
|
406
|
+
# one-to-one: user_id is primary key and foreign key
|
|
407
|
+
user_id = Column(Integer, ForeignKey("users.id"), primary_key=True)
|
|
408
|
+
|
|
409
|
+
# profile attributes
|
|
410
|
+
phone = Column(String(20))
|
|
411
|
+
address_line1 = Column(String(100))
|
|
412
|
+
address_line2 = Column(String(100))
|
|
413
|
+
city = Column(String(50))
|
|
414
|
+
state = Column(String(50))
|
|
415
|
+
postal_code = Column(String(20))
|
|
416
|
+
country = Column(String(2)) # ISO country code
|
|
417
|
+
|
|
418
|
+
birth_date = Column(Date)
|
|
419
|
+
website = Column(String(200))
|
|
420
|
+
linkedin_url = Column(String(200))
|
|
421
|
+
github_url = Column(String(200))
|
|
422
|
+
|
|
423
|
+
preferences = Column(Text) # JSON string for flexible preferences
|
|
424
|
+
|
|
425
|
+
# relationship
|
|
426
|
+
user = relationship("User", back_populates="profile")
|
|
427
|
+
|
|
428
|
+
def __repr__(self):
|
|
429
|
+
return f"<UserProfile(user_id={self.user_id})>"
|
|
430
|
+
</content>
|
|
431
|
+
</edit>
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
add profile relationship to User
|
|
435
|
+
|
|
436
|
+
<read><file>src/models/user.py</file></read>
|
|
437
|
+
|
|
438
|
+
<edit>
|
|
439
|
+
<file>src/models/user.py</file>
|
|
440
|
+
<find>
|
|
441
|
+
# relationships
|
|
442
|
+
posts = relationship("Post", back_populates="author")
|
|
443
|
+
</find>
|
|
444
|
+
<replace>
|
|
445
|
+
# relationships
|
|
446
|
+
posts = relationship("Post", back_populates="author")
|
|
447
|
+
profile = relationship("UserProfile", back_populates="user", uselist=False)
|
|
448
|
+
</replace>
|
|
449
|
+
</edit>
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
PHASE 4: INDEXING STRATEGY
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
primary indexes
|
|
456
|
+
|
|
457
|
+
every table needs a primary key:
|
|
458
|
+
- most common: auto-increment integer
|
|
459
|
+
- for distributed systems: uuid
|
|
460
|
+
- for natural keys: composite key on unique columns
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
unique indexes
|
|
464
|
+
|
|
465
|
+
enforce uniqueness and speed lookups:
|
|
466
|
+
|
|
467
|
+
<read><file>src/models/user.py</file></read>
|
|
468
|
+
|
|
469
|
+
<edit>
|
|
470
|
+
<file>src/models/user.py</file>
|
|
471
|
+
<content>
|
|
472
|
+
"""User entity model with indexes."""
|
|
473
|
+
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index, UniqueConstraint
|
|
474
|
+
from sqlalchemy.orm import declarative_base, relationship
|
|
475
|
+
from datetime import datetime
|
|
476
|
+
|
|
477
|
+
Base = declarative_base()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class User(Base):
|
|
481
|
+
"""User entity representing application users."""
|
|
482
|
+
|
|
483
|
+
__tablename__ = "users"
|
|
484
|
+
|
|
485
|
+
# primary key - always required
|
|
486
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
487
|
+
|
|
488
|
+
# required attributes with unique indexes
|
|
489
|
+
email = Column(String(255), nullable=False)
|
|
490
|
+
username = Column(String(50), nullable=False)
|
|
491
|
+
password_hash = Column(String(255), nullable=False)
|
|
492
|
+
|
|
493
|
+
# optional attributes
|
|
494
|
+
full_name = Column(String(100))
|
|
495
|
+
avatar_url = Column(String(500))
|
|
496
|
+
bio = Column(String(500))
|
|
497
|
+
|
|
498
|
+
# status fields
|
|
499
|
+
is_active = Column(Boolean, default=True, nullable=False)
|
|
500
|
+
is_verified = Column(Boolean, default=False, nullable=False)
|
|
501
|
+
|
|
502
|
+
# timestamps
|
|
503
|
+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
504
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
505
|
+
last_login_at = Column(DateTime)
|
|
506
|
+
|
|
507
|
+
# relationships
|
|
508
|
+
posts = relationship("Post", back_populates="author")
|
|
509
|
+
profile = relationship("UserProfile", back_populates="user", uselist=False)
|
|
510
|
+
|
|
511
|
+
# constraints
|
|
512
|
+
__table_args__ = (
|
|
513
|
+
UniqueConstraint("email", name="uq_users_email"),
|
|
514
|
+
UniqueConstraint("username", name="uq_users_username"),
|
|
515
|
+
Index("idx_users_email", "email"),
|
|
516
|
+
Index("idx_users_username", "username"),
|
|
517
|
+
Index("idx_users_is_active", "is_active"),
|
|
518
|
+
Index("idx_users_created_at", "created_at"),
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
def __repr__(self):
|
|
522
|
+
return f"<User(id={self.id}, username={self.username})>"
|
|
523
|
+
</content>
|
|
524
|
+
</edit>
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
composite indexes
|
|
528
|
+
|
|
529
|
+
for queries filtering on multiple columns:
|
|
530
|
+
|
|
531
|
+
<create>
|
|
532
|
+
<file>src/models/order.py</file>
|
|
533
|
+
<content>
|
|
534
|
+
"""Order entity with composite indexes."""
|
|
535
|
+
from sqlalchemy import Column, Integer, String, DateTime, Numeric, ForeignKey, Index
|
|
536
|
+
from sqlalchemy.orm import relationship
|
|
537
|
+
|
|
538
|
+
from .user import Base
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class Order(Base):
|
|
542
|
+
"""Customer orders."""
|
|
543
|
+
|
|
544
|
+
__tablename__ = "orders"
|
|
545
|
+
|
|
546
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
547
|
+
order_number = Column(String(50), nullable=False, unique=True)
|
|
548
|
+
|
|
549
|
+
# foreign key
|
|
550
|
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
551
|
+
|
|
552
|
+
# order details
|
|
553
|
+
status = Column(String(20), nullable=False, default="pending")
|
|
554
|
+
total = Column(Numeric(10, 2), nullable=False)
|
|
555
|
+
|
|
556
|
+
# timestamps
|
|
557
|
+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
558
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
559
|
+
|
|
560
|
+
# relationships
|
|
561
|
+
user = relationship("User")
|
|
562
|
+
|
|
563
|
+
# composite indexes for common query patterns
|
|
564
|
+
__table_args__ = (
|
|
565
|
+
# index for user's orders by status
|
|
566
|
+
Index("idx_orders_user_status", "user_id", "status"),
|
|
567
|
+
# index for user's orders by date
|
|
568
|
+
Index("idx_orders_user_created", "user_id", "created_at"),
|
|
569
|
+
# index for orders by status and date (dashboard query)
|
|
570
|
+
Index("idx_orders_status_created", "status", "created_at"),
|
|
571
|
+
)
|
|
572
|
+
</content>
|
|
573
|
+
</create>
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
when to index
|
|
577
|
+
|
|
578
|
+
index these columns:
|
|
579
|
+
[x] primary keys (automatic)
|
|
580
|
+
[x] foreign keys
|
|
581
|
+
[x] columns in WHERE clauses
|
|
582
|
+
[x] columns in JOIN conditions
|
|
583
|
+
[x] columns in ORDER BY
|
|
584
|
+
[x] columns frequently searched
|
|
585
|
+
|
|
586
|
+
dont index:
|
|
587
|
+
[x] columns with low cardinality (boolean, small enums)
|
|
588
|
+
[x] columns frequently updated
|
|
589
|
+
[x] very wide columns (long text)
|
|
590
|
+
[x] tables that are write-heavy and rarely read
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
PHASE 5: MIGRATION SETUP WITH ALEMBIC
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
initialize alembic
|
|
597
|
+
|
|
598
|
+
<terminal>alembic init alembic</terminal>
|
|
599
|
+
|
|
600
|
+
this creates:
|
|
601
|
+
- alembic/
|
|
602
|
+
- alembic.ini
|
|
603
|
+
- alembic/env.py
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
configure alembic.ini
|
|
607
|
+
|
|
608
|
+
<read><file>alembic.ini</file></read>
|
|
609
|
+
|
|
610
|
+
<edit>
|
|
611
|
+
<file>alembic.ini</file>
|
|
612
|
+
<find>
|
|
613
|
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
614
|
+
</find>
|
|
615
|
+
<replace>
|
|
616
|
+
sqlalchemy.url = postgresql://user:password@localhost:5432/dbname
|
|
617
|
+
# or use environment variable:
|
|
618
|
+
# sqlalchemy.url = ${DATABASE_URL}
|
|
619
|
+
</replace>
|
|
620
|
+
</edit>
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
configure env.py
|
|
624
|
+
|
|
625
|
+
<read><file>alembic/env.py</file></read>
|
|
626
|
+
|
|
627
|
+
<edit>
|
|
628
|
+
<file>alembic/env.py</file>
|
|
629
|
+
<find>
|
|
630
|
+
from logging.config import fileConfig
|
|
631
|
+
|
|
632
|
+
from sqlalchemy import engine_from_config
|
|
633
|
+
from sqlalchemy import pool
|
|
634
|
+
|
|
635
|
+
from alembic import context
|
|
636
|
+
|
|
637
|
+
# this is the Alembic Config object
|
|
638
|
+
config = context.config
|
|
639
|
+
|
|
640
|
+
# add your model's MetaData object here
|
|
641
|
+
# for 'autogenerate' support
|
|
642
|
+
# target_metadata = mymodel.Base.metadata
|
|
643
|
+
target_metadata = None
|
|
644
|
+
</find>
|
|
645
|
+
<replace>
|
|
646
|
+
from logging.config import fileConfig
|
|
647
|
+
|
|
648
|
+
from sqlalchemy import engine_from_config
|
|
649
|
+
from sqlalchemy import pool
|
|
650
|
+
|
|
651
|
+
from alembic import context
|
|
652
|
+
|
|
653
|
+
# import your models
|
|
654
|
+
import sys
|
|
655
|
+
from pathlib import Path
|
|
656
|
+
sys.path.append(str(Path(__file__).parent.parent))
|
|
657
|
+
|
|
658
|
+
from src.models.user import Base
|
|
659
|
+
from src.models.post import Post
|
|
660
|
+
from src.models.tag import Tag
|
|
661
|
+
from src.models.profile import UserProfile
|
|
662
|
+
from src.models.order import Order
|
|
663
|
+
|
|
664
|
+
# this is the Alembic Config object
|
|
665
|
+
config = context.config
|
|
666
|
+
|
|
667
|
+
# add your model's MetaData object here
|
|
668
|
+
target_metadata = Base.metadata
|
|
669
|
+
</replace>
|
|
670
|
+
</edit>
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
PHASE 6: CREATING MIGRATIONS
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
initial migration
|
|
677
|
+
|
|
678
|
+
<terminal>alembic revision --autogenerate -m "Initial schema"</terminal>
|
|
679
|
+
|
|
680
|
+
this generates a new migration file in alembic/versions/.
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
review generated migration
|
|
684
|
+
|
|
685
|
+
<read><file>alembic/versions/001_initial_schema.py</file></read>
|
|
686
|
+
|
|
687
|
+
check that:
|
|
688
|
+
[ ] all tables are created
|
|
689
|
+
[ ] indexes are defined
|
|
690
|
+
[ ] foreign keys have proper constraints
|
|
691
|
+
[ ] no extra tables from imports
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
manual migration for control
|
|
695
|
+
|
|
696
|
+
<terminal>alembic revision -m "Add user email verification"</terminal>
|
|
697
|
+
|
|
698
|
+
<read><file>alembic/versions/002_add_user_email_verification.py</file></read>
|
|
699
|
+
|
|
700
|
+
<edit>
|
|
701
|
+
<file>alembic/versions/002_add_user_email_verification.py</file>
|
|
702
|
+
<find>
|
|
703
|
+
"""${message}
|
|
704
|
+
|
|
705
|
+
Revision ID: ${up_revision}
|
|
706
|
+
Revises: ${down_revision}
|
|
707
|
+
Create Date: ${create_date}
|
|
708
|
+
|
|
709
|
+
"""
|
|
710
|
+
from alembic import op
|
|
711
|
+
import sqlalchemy as sa
|
|
712
|
+
${imports if imports else ""}
|
|
713
|
+
|
|
714
|
+
# revision identifiers, used by Alembic.
|
|
715
|
+
revision = ${repr(up_revision)}
|
|
716
|
+
down_revision = ${repr(down_revision)}
|
|
717
|
+
branch_labels = ${repr(branch_labels)}
|
|
718
|
+
depends_on = ${repr(depends_on)}
|
|
719
|
+
|
|
720
|
+
def upgrade() -> None:
|
|
721
|
+
${upgrades if upgrades else "pass"}
|
|
722
|
+
|
|
723
|
+
def downgrade() -> None:
|
|
724
|
+
${downgrades if downgrades else "pass"}
|
|
725
|
+
</find>
|
|
726
|
+
<replace>
|
|
727
|
+
"""Add user email verification token and timestamp
|
|
728
|
+
|
|
729
|
+
Revision ID: 002_add_verification
|
|
730
|
+
Revises: 001_initial_schema
|
|
731
|
+
Create Date: 2024-01-15
|
|
732
|
+
|
|
733
|
+
"""
|
|
734
|
+
from alembic import op
|
|
735
|
+
import sqlalchemy as sa
|
|
736
|
+
|
|
737
|
+
# revision identifiers, used by Alembic.
|
|
738
|
+
revision = "002_add_verification"
|
|
739
|
+
down_revision = "001_initial_schema"
|
|
740
|
+
branch_labels = None
|
|
741
|
+
depends_on = None
|
|
742
|
+
|
|
743
|
+
def upgrade() -> None:
|
|
744
|
+
# add verification_token column
|
|
745
|
+
op.add_column(
|
|
746
|
+
"users",
|
|
747
|
+
sa.Column("verification_token", sa.String(255), nullable=True)
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
# add verified_at column
|
|
751
|
+
op.add_column(
|
|
752
|
+
"users",
|
|
753
|
+
sa.Column("verified_at", sa.DateTime(), nullable=True)
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# create index for token lookup
|
|
757
|
+
op.create_index(
|
|
758
|
+
"idx_users_verification_token",
|
|
759
|
+
"users",
|
|
760
|
+
["verification_token"]
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
def downgrade() -> None:
|
|
764
|
+
# remove index
|
|
765
|
+
op.drop_index("idx_users_verification_token", "users")
|
|
766
|
+
|
|
767
|
+
# remove columns
|
|
768
|
+
op.drop_column("users", "verified_at")
|
|
769
|
+
op.drop_column("users", "verification_token")
|
|
770
|
+
</replace>
|
|
771
|
+
</edit>
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
PHASE 7: RUNNING AND MANAGING MIGRATIONS
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
apply migrations
|
|
778
|
+
|
|
779
|
+
<terminal>alembic upgrade head</terminal>
|
|
780
|
+
|
|
781
|
+
this runs all pending migrations.
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
check current version
|
|
785
|
+
|
|
786
|
+
<terminal>alembic current</terminal>
|
|
787
|
+
|
|
788
|
+
view migration history:
|
|
789
|
+
<terminal>alembic history</terminal>
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
rollback one migration
|
|
793
|
+
|
|
794
|
+
<terminal>alembic downgrade -1</terminal>
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
rollback to specific version
|
|
798
|
+
|
|
799
|
+
<terminal>alembic downgrade 001_initial_schema</terminal>
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
redo last migration
|
|
803
|
+
|
|
804
|
+
useful during development when testing migrations:
|
|
805
|
+
|
|
806
|
+
<terminal>alembic downgrade -1 && alembic upgrade head</terminal>
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
PHASE 8: DATA MIGRATIONS
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
migrating existing data
|
|
813
|
+
|
|
814
|
+
sometimes you need to transform data during migration:
|
|
815
|
+
|
|
816
|
+
<create>
|
|
817
|
+
<file>alembic/versions/003_migrate_user_status.py</file>
|
|
818
|
+
<content>
|
|
819
|
+
"""Migrate user status to separate columns
|
|
820
|
+
|
|
821
|
+
Revision ID: 003_migrate_user_status
|
|
822
|
+
Revises: 002_add_verification
|
|
823
|
+
Create Date: 2024-01-16
|
|
824
|
+
|
|
825
|
+
"""
|
|
826
|
+
from alembic import op
|
|
827
|
+
import sqlalchemy as sa
|
|
828
|
+
|
|
829
|
+
revision = "003_migrate_user_status"
|
|
830
|
+
down_revision = "002_add_verification"
|
|
831
|
+
branch_labels = None
|
|
832
|
+
depends_on = None
|
|
833
|
+
|
|
834
|
+
def upgrade() -> None:
|
|
835
|
+
# step 1: add new columns
|
|
836
|
+
op.add_column("users", sa.Column("is_active", sa.Boolean(), nullable=True))
|
|
837
|
+
op.add_column("users", sa.Column("is_banned", sa.Boolean(), nullable=True))
|
|
838
|
+
|
|
839
|
+
# step 2: migrate data from old status column
|
|
840
|
+
connection = op.get_bind()
|
|
841
|
+
|
|
842
|
+
# set is_active based on status
|
|
843
|
+
connection.execute(
|
|
844
|
+
sa.text("""
|
|
845
|
+
UPDATE users
|
|
846
|
+
SET is_active = (status = 'active')
|
|
847
|
+
""")
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
# set is_banned based on status
|
|
851
|
+
connection.execute(
|
|
852
|
+
sa.text("""
|
|
853
|
+
UPDATE users
|
|
854
|
+
SET is_banned = (status = 'banned')
|
|
855
|
+
""")
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# step 3: make new columns non-nullable with defaults
|
|
859
|
+
op.alter_column("users", "is_active", nullable=False, server_default="true")
|
|
860
|
+
op.alter_column("users", "is_banned", nullable=False, server_default="false")
|
|
861
|
+
|
|
862
|
+
# step 4: remove old status column
|
|
863
|
+
op.drop_column("users", "status")
|
|
864
|
+
|
|
865
|
+
def downgrade() -> None:
|
|
866
|
+
# step 1: add back old status column
|
|
867
|
+
op.add_column("users", sa.Column("status", sa.String(20), nullable=True))
|
|
868
|
+
|
|
869
|
+
# step 2: migrate data back
|
|
870
|
+
connection = op.get_bind()
|
|
871
|
+
|
|
872
|
+
connection.execute(
|
|
873
|
+
sa.text("""
|
|
874
|
+
UPDATE users
|
|
875
|
+
SET status = CASE
|
|
876
|
+
WHEN is_banned THEN 'banned'
|
|
877
|
+
WHEN NOT is_active THEN 'inactive'
|
|
878
|
+
ELSE 'active'
|
|
879
|
+
END
|
|
880
|
+
""")
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
# step 3: make status non-nullable
|
|
884
|
+
op.alter_column("users", "status", nullable=False)
|
|
885
|
+
|
|
886
|
+
# step 4: remove new columns
|
|
887
|
+
op.drop_column("users", "is_banned")
|
|
888
|
+
op.drop_column("users", "is_active")
|
|
889
|
+
</content>
|
|
890
|
+
</create>
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
PHASE 9: DATABASE CONSTRAINTS
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
check constraints
|
|
897
|
+
|
|
898
|
+
enforce data rules at database level:
|
|
899
|
+
|
|
900
|
+
<create>
|
|
901
|
+
<file>src/models/constraints.py</file>
|
|
902
|
+
<content>
|
|
903
|
+
"""Models with database constraints."""
|
|
904
|
+
from sqlalchemy import Column, Integer, String, DateTime, Numeric, CheckConstraint
|
|
905
|
+
from sqlalchemy.orm import declarative_base
|
|
906
|
+
from datetime import datetime
|
|
907
|
+
|
|
908
|
+
Base = declarative_base()
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
class Product(Base):
|
|
912
|
+
"""Product with validation constraints."""
|
|
913
|
+
|
|
914
|
+
__tablename__ = "products"
|
|
915
|
+
|
|
916
|
+
id = Column(Integer, primary_key=True)
|
|
917
|
+
name = Column(String(100), nullable=False)
|
|
918
|
+
price = Column(Numeric(10, 2), nullable=False)
|
|
919
|
+
quantity = Column(Integer, nullable=False)
|
|
920
|
+
discount_percent = Column(Integer, default=0)
|
|
921
|
+
|
|
922
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
923
|
+
|
|
924
|
+
# constraints
|
|
925
|
+
__table_args__ = (
|
|
926
|
+
# price must be positive
|
|
927
|
+
CheckConstraint("price > 0", name="check_positive_price"),
|
|
928
|
+
# quantity cannot be negative
|
|
929
|
+
CheckConstraint("quantity >= 0", name="check_nonnegative_quantity"),
|
|
930
|
+
# discount between 0 and 100
|
|
931
|
+
CheckConstraint("discount_percent >= 0 AND discount_percent <= 100",
|
|
932
|
+
name="check_valid_discount"),
|
|
933
|
+
# final price after discount must be positive
|
|
934
|
+
CheckConstraint("price * (1 - discount_percent / 100.0) > 0",
|
|
935
|
+
name="check_positive_final_price"),
|
|
936
|
+
)
|
|
937
|
+
</content>
|
|
938
|
+
</create>
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
enum constraints
|
|
942
|
+
|
|
943
|
+
<create>
|
|
944
|
+
<file>src/models/enums.py</file>
|
|
945
|
+
<content>
|
|
946
|
+
"""Models with enum constraints."""
|
|
947
|
+
from sqlalchemy import Column, Integer, String, DateTime, Enum
|
|
948
|
+
from sqlalchemy.orm import declarative_base
|
|
949
|
+
from enum import Enum as PyEnum
|
|
950
|
+
from datetime import datetime
|
|
951
|
+
|
|
952
|
+
Base = declarative_base()
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
class OrderStatus(PyEnum):
|
|
956
|
+
"""Order status enumeration."""
|
|
957
|
+
PENDING = "pending"
|
|
958
|
+
PROCESSING = "processing"
|
|
959
|
+
SHIPPED = "shipped"
|
|
960
|
+
DELIVERED = "delivered"
|
|
961
|
+
CANCELLED = "cancelled"
|
|
962
|
+
REFUNDED = "refunded"
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
class Order(Base):
|
|
966
|
+
"""Order with enum status."""
|
|
967
|
+
|
|
968
|
+
__tablename__ = "orders"
|
|
969
|
+
|
|
970
|
+
id = Column(Integer, primary_key=True)
|
|
971
|
+
status = Column(
|
|
972
|
+
Enum(OrderStatus),
|
|
973
|
+
default=OrderStatus.PENDING,
|
|
974
|
+
nullable=False
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
978
|
+
</content>
|
|
979
|
+
</create>
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
PHASE 10: QUERY OPTIMIZATION
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
avoid N+1 queries
|
|
986
|
+
|
|
987
|
+
the classic anti-pattern:
|
|
988
|
+
|
|
989
|
+
# bad: N+1 query
|
|
990
|
+
users = session.query(User).all()
|
|
991
|
+
for user in users:
|
|
992
|
+
print(user.posts) # each iteration triggers a query!
|
|
993
|
+
|
|
994
|
+
the fix: eager loading
|
|
995
|
+
|
|
996
|
+
from sqlalchemy.orm import selectinload, joinedload
|
|
997
|
+
|
|
998
|
+
# good: single query with join
|
|
999
|
+
users = session.query(User).options(selectinload(User.posts)).all()
|
|
1000
|
+
for user in users:
|
|
1001
|
+
print(user.posts) # already loaded!
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
select the right join type
|
|
1005
|
+
|
|
1006
|
+
selectinload - good for one-to-many, separate queries
|
|
1007
|
+
users = session.query(User).options(selectinload(User.posts)).all()
|
|
1008
|
+
|
|
1009
|
+
joinedload - good for one-to-one, single query with join
|
|
1010
|
+
users = session.query(User).options(joinedload(User.profile)).all()
|
|
1011
|
+
|
|
1012
|
+
subqueryload - for nested relationships
|
|
1013
|
+
users = session.query(User).options(
|
|
1014
|
+
subqueryload(User.posts).selectinload(Post.tags)
|
|
1015
|
+
).all()
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
only select what you need
|
|
1019
|
+
|
|
1020
|
+
# bad: fetches all columns
|
|
1021
|
+
users = session.query(User).all()
|
|
1022
|
+
|
|
1023
|
+
# good: only needed columns
|
|
1024
|
+
user_names = session.query(User.username, User.email).all()
|
|
1025
|
+
|
|
1026
|
+
# good: use entities
|
|
1027
|
+
users = session.query(User.username, User.email).all()
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
use pagination
|
|
1031
|
+
|
|
1032
|
+
<create>
|
|
1033
|
+
<file>src/repository/pagination.py</file>
|
|
1034
|
+
<content>
|
|
1035
|
+
"""Pagination utilities."""
|
|
1036
|
+
from typing import Generic, TypeVar, List
|
|
1037
|
+
from dataclasses import dataclass
|
|
1038
|
+
from sqlalchemy.orm import Query
|
|
1039
|
+
|
|
1040
|
+
T = TypeVar("T")
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
@dataclass
|
|
1044
|
+
class PaginatedResult(Generic[T]):
|
|
1045
|
+
"""Result of paginated query."""
|
|
1046
|
+
items: List[T]
|
|
1047
|
+
total: int
|
|
1048
|
+
page: int
|
|
1049
|
+
page_size: int
|
|
1050
|
+
has_more: bool
|
|
1051
|
+
|
|
1052
|
+
@property
|
|
1053
|
+
def total_pages(self) -> int:
|
|
1054
|
+
"""Calculate total pages."""
|
|
1055
|
+
return (self.total + self.page_size - 1) // self.page_size
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def paginate(query: Query, page: int = 1, page_size: int = 20) -> PaginatedResult:
|
|
1059
|
+
"""Apply pagination to a SQLAlchemy query."""
|
|
1060
|
+
# count total
|
|
1061
|
+
total = query.count()
|
|
1062
|
+
|
|
1063
|
+
# calculate offset
|
|
1064
|
+
offset = (page - 1) * page_size
|
|
1065
|
+
|
|
1066
|
+
# fetch page
|
|
1067
|
+
items = query.offset(offset).limit(page_size).all()
|
|
1068
|
+
|
|
1069
|
+
return PaginatedResult(
|
|
1070
|
+
items=items,
|
|
1071
|
+
total=total,
|
|
1072
|
+
page=page,
|
|
1073
|
+
page_size=page_size,
|
|
1074
|
+
has_more=offset + page_size < total
|
|
1075
|
+
)
|
|
1076
|
+
</content>
|
|
1077
|
+
</create>
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
PHASE 11: TRANSACTION MANAGEMENT
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
transaction basics
|
|
1084
|
+
|
|
1085
|
+
from sqlalchemy import create_engine
|
|
1086
|
+
from sqlalchemy.orm import sessionmaker
|
|
1087
|
+
|
|
1088
|
+
engine = create_engine("postgresql://...")
|
|
1089
|
+
Session = sessionmaker(bind=engine)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
# explicit transaction
|
|
1093
|
+
session = Session()
|
|
1094
|
+
try:
|
|
1095
|
+
user = User(username="alice", email="alice@example.com")
|
|
1096
|
+
session.add(user)
|
|
1097
|
+
session.commit()
|
|
1098
|
+
except Exception as e:
|
|
1099
|
+
session.rollback()
|
|
1100
|
+
raise
|
|
1101
|
+
finally:
|
|
1102
|
+
session.close()
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
context manager for transactions
|
|
1106
|
+
|
|
1107
|
+
<create>
|
|
1108
|
+
<file>src/database/transaction.py</file>
|
|
1109
|
+
<content>
|
|
1110
|
+
"""Transaction management utilities."""
|
|
1111
|
+
from contextlib import contextmanager
|
|
1112
|
+
from typing import Generator
|
|
1113
|
+
from sqlalchemy.orm import Session
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
@contextmanager
|
|
1117
|
+
def transaction(session: Session) -> Generator[Session, None, None]:
|
|
1118
|
+
"""Context manager for database transactions.
|
|
1119
|
+
|
|
1120
|
+
Usage:
|
|
1121
|
+
with transaction(session) as s:
|
|
1122
|
+
user = User(username="alice")
|
|
1123
|
+
s.add(user)
|
|
1124
|
+
# automatically commits on success, rolls back on error
|
|
1125
|
+
"""
|
|
1126
|
+
try:
|
|
1127
|
+
yield session
|
|
1128
|
+
session.commit()
|
|
1129
|
+
except Exception:
|
|
1130
|
+
session.rollback()
|
|
1131
|
+
raise
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
@contextmanager
|
|
1135
|
+
def nested_transaction(session: Session) -> Generator[Session, None, None]:
|
|
1136
|
+
"""Context manager for nested (savepoint) transactions.
|
|
1137
|
+
|
|
1138
|
+
Useful for tests or when you need to rollback inner transactions
|
|
1139
|
+
while keeping outer ones.
|
|
1140
|
+
"""
|
|
1141
|
+
try:
|
|
1142
|
+
nested = session.begin_nested()
|
|
1143
|
+
yield session
|
|
1144
|
+
nested.commit()
|
|
1145
|
+
except Exception:
|
|
1146
|
+
nested.rollback()
|
|
1147
|
+
raise
|
|
1148
|
+
</content>
|
|
1149
|
+
</create>
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
PHASE 12: SOFT DELETES
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
implementing soft deletes
|
|
1156
|
+
|
|
1157
|
+
<create>
|
|
1158
|
+
<file>src/models/soft_delete.py</file>
|
|
1159
|
+
<content>
|
|
1160
|
+
"""Soft delete mixin for models."""
|
|
1161
|
+
from sqlalchemy import Column, DateTime, Boolean, event
|
|
1162
|
+
from sqlalchemy.orm import declarative_mixin
|
|
1163
|
+
from datetime import datetime
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
@declarative_mixin
|
|
1167
|
+
class SoftDeleteMixin:
|
|
1168
|
+
"""Add soft delete functionality to a model."""
|
|
1169
|
+
|
|
1170
|
+
deleted_at = Column(DateTime, nullable=True, index=True)
|
|
1171
|
+
is_deleted = Column(Boolean, default=False, nullable=False, index=True)
|
|
1172
|
+
|
|
1173
|
+
def soft_delete(self):
|
|
1174
|
+
"""Mark record as deleted without removing from database."""
|
|
1175
|
+
self.is_deleted = True
|
|
1176
|
+
self.deleted_at = datetime.utcnow()
|
|
1177
|
+
|
|
1178
|
+
def restore(self):
|
|
1179
|
+
"""Restore soft-deleted record."""
|
|
1180
|
+
self.is_deleted = False
|
|
1181
|
+
self.deleted_at = None
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
class SoftDeleteQuery:
|
|
1185
|
+
"""Query mixin for filtering soft-deleted records."""
|
|
1186
|
+
|
|
1187
|
+
def _with_deleted(self):
|
|
1188
|
+
"""Include soft-deleted records."""
|
|
1189
|
+
return self.enable_assertions(False).filter()
|
|
1190
|
+
|
|
1191
|
+
def without_deleted(self):
|
|
1192
|
+
"""Exclude soft-deleted records."""
|
|
1193
|
+
return self.filter_by(is_deleted=False)
|
|
1194
|
+
</content>
|
|
1195
|
+
</create>
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
using soft deletes in models
|
|
1199
|
+
|
|
1200
|
+
from sqlalchemy import Column, Integer, String
|
|
1201
|
+
from sqlalchemy.orm import declarative_base
|
|
1202
|
+
|
|
1203
|
+
Base = declarative_base()
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
class User(SoftDeleteMixin, Base):
|
|
1207
|
+
"""User with soft delete."""
|
|
1208
|
+
__tablename__ = "users"
|
|
1209
|
+
|
|
1210
|
+
id = Column(Integer, primary_key=True)
|
|
1211
|
+
username = Column(String(50), nullable=False)
|
|
1212
|
+
email = Column(String(255), nullable=False)
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
PHASE 13: AUDIT COLUMNS AND HISTORY
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
audit columns
|
|
1219
|
+
|
|
1220
|
+
<create>
|
|
1221
|
+
<file>src/models/audit.py</file>
|
|
1222
|
+
<content>
|
|
1223
|
+
"""Audit columns for tracking record changes."""
|
|
1224
|
+
from sqlalchemy import Column, DateTime, Integer, ForeignKey, event
|
|
1225
|
+
from sqlalchemy.orm import declarative_mixin, relationship
|
|
1226
|
+
from datetime import datetime
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
@declarative_mixin
|
|
1230
|
+
class AuditMixin:
|
|
1231
|
+
"""Add audit columns to track record lifecycle."""
|
|
1232
|
+
|
|
1233
|
+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
1234
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
1235
|
+
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
1236
|
+
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
1237
|
+
|
|
1238
|
+
# relationships
|
|
1239
|
+
created_by = relationship("User", foreign_keys=[created_by_id])
|
|
1240
|
+
updated_by = relationship("User", foreign_keys=[updated_by_id])
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
@declarative_mixin
|
|
1244
|
+
class FullAuditMixin(AuditMixin):
|
|
1245
|
+
"""Extended audit with deletion tracking."""
|
|
1246
|
+
|
|
1247
|
+
deleted_at = Column(DateTime, nullable=True)
|
|
1248
|
+
deleted_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
1249
|
+
|
|
1250
|
+
# relationship
|
|
1251
|
+
deleted_by = relationship("User", foreign_keys=[deleted_by_id])
|
|
1252
|
+
</content>
|
|
1253
|
+
</create>
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
audit table for history tracking
|
|
1257
|
+
|
|
1258
|
+
<create>
|
|
1259
|
+
<file>src/models/audit_log.py</file>
|
|
1260
|
+
<content>
|
|
1261
|
+
"""Audit log table for tracking changes."""
|
|
1262
|
+
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, JSON
|
|
1263
|
+
from sqlalchemy.orm import declarative_base, relationship
|
|
1264
|
+
from datetime import datetime
|
|
1265
|
+
|
|
1266
|
+
Base = declarative_base()
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
class AuditLog(Base):
|
|
1270
|
+
"""Record of all changes to tracked entities."""
|
|
1271
|
+
|
|
1272
|
+
__tablename__ = "audit_logs"
|
|
1273
|
+
|
|
1274
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
1275
|
+
|
|
1276
|
+
# what changed
|
|
1277
|
+
table_name = Column(String(100), nullable=False, index=True)
|
|
1278
|
+
record_id = Column(Integer, nullable=False, index=True)
|
|
1279
|
+
action = Column(String(20), nullable=False) # INSERT, UPDATE, DELETE
|
|
1280
|
+
|
|
1281
|
+
# who changed it
|
|
1282
|
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
|
1283
|
+
user = relationship("User", foreign_keys=[user_id])
|
|
1284
|
+
|
|
1285
|
+
# what changed
|
|
1286
|
+
old_values = Column(JSON) # {"field": "old_value"}
|
|
1287
|
+
new_values = Column(JSON) # {"field": "new_value"}
|
|
1288
|
+
changed_fields = Column(JSON) # ["field1", "field2"]
|
|
1289
|
+
|
|
1290
|
+
# metadata
|
|
1291
|
+
ip_address = Column(String(45)) # IPv6 support
|
|
1292
|
+
user_agent = Column(String(500))
|
|
1293
|
+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
|
1294
|
+
|
|
1295
|
+
def __repr__(self):
|
|
1296
|
+
return f"<AuditLog(table={self.table_name}, record_id={self.record_id}, action={self.action})>"
|
|
1297
|
+
</content>
|
|
1298
|
+
</create>
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
PHASE 14: DATABASE TESTING
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
test database fixtures
|
|
1305
|
+
|
|
1306
|
+
<create>
|
|
1307
|
+
<file>tests/conftest.py</file>
|
|
1308
|
+
<content>
|
|
1309
|
+
"""Pytest fixtures for database testing."""
|
|
1310
|
+
import pytest
|
|
1311
|
+
from sqlalchemy import create_engine
|
|
1312
|
+
from sqlalchemy.orm import sessionmaker
|
|
1313
|
+
from tempfile import NamedTemporaryFile
|
|
1314
|
+
import os
|
|
1315
|
+
|
|
1316
|
+
from src.models.user import Base
|
|
1317
|
+
from src.models.post import Post
|
|
1318
|
+
from src.models.tag import Tag
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
@pytest.fixture(scope="function")
|
|
1322
|
+
def test_db():
|
|
1323
|
+
"""Create an in-memory SQLite database for testing."""
|
|
1324
|
+
engine = create_engine("sqlite:///:memory:")
|
|
1325
|
+
|
|
1326
|
+
# create all tables
|
|
1327
|
+
Base.metadata.create_all(engine)
|
|
1328
|
+
|
|
1329
|
+
yield engine
|
|
1330
|
+
|
|
1331
|
+
# cleanup
|
|
1332
|
+
Base.metadata.drop_all(engine)
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
@pytest.fixture(scope="function")
|
|
1336
|
+
def db_session(test_db):
|
|
1337
|
+
"""Create a database session for testing."""
|
|
1338
|
+
Session = sessionmaker(bind=test_db)
|
|
1339
|
+
session = Session()
|
|
1340
|
+
|
|
1341
|
+
yield session
|
|
1342
|
+
|
|
1343
|
+
session.close()
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
@pytest.fixture
|
|
1347
|
+
def sample_user(db_session):
|
|
1348
|
+
"""Create a sample user for tests."""
|
|
1349
|
+
user = User(
|
|
1350
|
+
username="testuser",
|
|
1351
|
+
email="test@example.com",
|
|
1352
|
+
password_hash="hashed_password"
|
|
1353
|
+
)
|
|
1354
|
+
db_session.add(user)
|
|
1355
|
+
db_session.commit()
|
|
1356
|
+
return user
|
|
1357
|
+
</content>
|
|
1358
|
+
</create>
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
example tests
|
|
1362
|
+
|
|
1363
|
+
<create>
|
|
1364
|
+
<file>tests/test_models.py</file>
|
|
1365
|
+
<content>
|
|
1366
|
+
"""Tests for database models."""
|
|
1367
|
+
import pytest
|
|
1368
|
+
from sqlalchemy.exc import IntegrityError
|
|
1369
|
+
|
|
1370
|
+
from src.models.user import User
|
|
1371
|
+
from src.models.post import Post
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def test_create_user_succeeds(db_session):
|
|
1375
|
+
"""Test creating a user."""
|
|
1376
|
+
user = User(
|
|
1377
|
+
username="alice",
|
|
1378
|
+
email="alice@example.com",
|
|
1379
|
+
password_hash="hashed"
|
|
1380
|
+
)
|
|
1381
|
+
db_session.add(user)
|
|
1382
|
+
db_session.commit()
|
|
1383
|
+
|
|
1384
|
+
assert user.id is not None
|
|
1385
|
+
assert user.username == "alice"
|
|
1386
|
+
assert user.is_active is True
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def test_unique_email_enforced(db_session):
|
|
1390
|
+
"""Test that duplicate emails are rejected."""
|
|
1391
|
+
user1 = User(username="user1", email="same@example.com", password_hash="hash1")
|
|
1392
|
+
user2 = User(username="user2", email="same@example.com", password_hash="hash2")
|
|
1393
|
+
|
|
1394
|
+
db_session.add(user1)
|
|
1395
|
+
db_session.commit()
|
|
1396
|
+
|
|
1397
|
+
db_session.add(user2)
|
|
1398
|
+
with pytest.raises(IntegrityError):
|
|
1399
|
+
db_session.commit()
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def test_user_post_relationship(db_session):
|
|
1403
|
+
"""Test that users can have posts."""
|
|
1404
|
+
user = User(username="alice", email="alice@example.com", password_hash="hashed")
|
|
1405
|
+
post = Post(title="Test", slug="test", content="Content", author_id=1)
|
|
1406
|
+
|
|
1407
|
+
db_session.add(user)
|
|
1408
|
+
db_session.commit()
|
|
1409
|
+
|
|
1410
|
+
post.author_id = user.id
|
|
1411
|
+
db_session.add(post)
|
|
1412
|
+
db_session.commit()
|
|
1413
|
+
|
|
1414
|
+
assert post.author_id == user.id
|
|
1415
|
+
assert len(user.posts) == 1
|
|
1416
|
+
assert user.posts[0].title == "Test"
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def test_soft_delete(db_session):
|
|
1420
|
+
"""Test soft delete functionality."""
|
|
1421
|
+
user = User(username="alice", email="alice@example.com", password_hash="hashed")
|
|
1422
|
+
db_session.add(user)
|
|
1423
|
+
db_session.commit()
|
|
1424
|
+
|
|
1425
|
+
user_id = user.id
|
|
1426
|
+
user.soft_delete()
|
|
1427
|
+
db_session.commit()
|
|
1428
|
+
|
|
1429
|
+
# verify soft deleted
|
|
1430
|
+
retrieved = db_session.query(User).filter_by(id=user_id).first()
|
|
1431
|
+
assert retrieved.is_deleted is True
|
|
1432
|
+
assert retrieved.deleted_at is not None
|
|
1433
|
+
</content>
|
|
1434
|
+
</create>
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
PHASE 15: DATABASE RULES (STRICT MODE)
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
while this skill is active, these rules are MANDATORY:
|
|
1441
|
+
|
|
1442
|
+
[1] ALWAYS use migrations for schema changes
|
|
1443
|
+
never modify database schema directly
|
|
1444
|
+
every change must be reproducible
|
|
1445
|
+
|
|
1446
|
+
[2] NEVER use reserved words as identifiers
|
|
1447
|
+
avoid: user, order, group, select, where
|
|
1448
|
+
use: users, orders, groups, or prefix: app_user
|
|
1449
|
+
|
|
1450
|
+
[3] EVERY table needs a primary key
|
|
1451
|
+
no exceptions
|
|
1452
|
+
prefer auto-increment integers for new tables
|
|
1453
|
+
|
|
1454
|
+
[4] FOREIGN KEYS are not optional
|
|
1455
|
+
enforce referential integrity at database level
|
|
1456
|
+
add proper indexes on foreign keys
|
|
1457
|
+
|
|
1458
|
+
[5] TIMESTAMPS on every table
|
|
1459
|
+
created_at is mandatory
|
|
1460
|
+
updated_at for mutable data
|
|
1461
|
+
deleted_at for soft deletes
|
|
1462
|
+
|
|
1463
|
+
[6] WRITE both upgrade and downgrade
|
|
1464
|
+
migrations must be reversible
|
|
1465
|
+
never leave a migration half-written
|
|
1466
|
+
|
|
1467
|
+
[7] TEST migrations on sample data
|
|
1468
|
+
verify upgrade works
|
|
1469
|
+
verify downgrade works
|
|
1470
|
+
verify data migration is correct
|
|
1471
|
+
|
|
1472
|
+
[8] USE appropriate data types
|
|
1473
|
+
money: Numeric, not Float
|
|
1474
|
+
enums: CHECK constraint or ENUM type
|
|
1475
|
+
json: JSON/JSONB type, not Text
|
|
1476
|
+
|
|
1477
|
+
[9] INDEX before you need it
|
|
1478
|
+
add indexes for foreign keys
|
|
1479
|
+
add indexes for query patterns
|
|
1480
|
+
review index usage periodically
|
|
1481
|
+
|
|
1482
|
+
[10] DOCUMENT your schema
|
|
1483
|
+
explain non-obvious relationships
|
|
1484
|
+
document denormalization decisions
|
|
1485
|
+
keep ER diagrams up to date
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
FINAL REMINDERS
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
database design is foundational
|
|
1492
|
+
|
|
1493
|
+
get it wrong and everything suffers.
|
|
1494
|
+
migrations are painful. refactoring is harder.
|
|
1495
|
+
think carefully before creating tables.
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
the database is source of truth
|
|
1499
|
+
|
|
1500
|
+
code can change. database persists.
|
|
1501
|
+
ensure the schema reflects reality.
|
|
1502
|
+
constraints and validations belong in the database.
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
normalize first, optimize later
|
|
1506
|
+
|
|
1507
|
+
start with normalized design.
|
|
1508
|
+
measure performance.
|
|
1509
|
+
denormalize only when needed.
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
migrations are code
|
|
1513
|
+
|
|
1514
|
+
treat them with same care as application code.
|
|
1515
|
+
review them. test them. version them.
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
when in doubt
|
|
1519
|
+
|
|
1520
|
+
add a timestamp.
|
|
1521
|
+
add an index.
|
|
1522
|
+
write a migration.
|
|
1523
|
+
measure twice, alter once.
|
|
1524
|
+
|
|
1525
|
+
now go design some schemas.
|