async-easy-model 0.2.4__py3-none-any.whl → 0.2.6__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.
@@ -6,8 +6,8 @@ from typing import Optional, Any
6
6
  from .model import EasyModel, init_db, db_config
7
7
  from sqlmodel import Field, Relationship as SQLModelRelationship
8
8
 
9
- __version__ = "0.2.4"
10
- __all__ = ["EasyModel", "init_db", "db_config", "Field", "Relationship", "Relation", "enable_auto_relationships", "disable_auto_relationships", "process_auto_relationships", "MigrationManager", "check_and_migrate_models"]
9
+ __version__ = "0.2.6"
10
+ __all__ = ["EasyModel", "init_db", "db_config", "Field", "Relationship", "Relation", "enable_auto_relationships", "disable_auto_relationships", "process_auto_relationships", "MigrationManager", "check_and_migrate_models", "ModelVisualizer"]
11
11
 
12
12
  # Create a more user-friendly Relationship function
13
13
  def Relationship(
@@ -43,4 +43,7 @@ from .relationships import Relation
43
43
  from .auto_relationships import enable_auto_relationships, disable_auto_relationships, process_auto_relationships
44
44
 
45
45
  # Add to __init__.py
46
- from .migrations import MigrationManager, check_and_migrate_models
46
+ from .migrations import MigrationManager, check_and_migrate_models
47
+
48
+ # Import the visualization helper
49
+ from .visualization import ModelVisualizer
async_easy_model/model.py CHANGED
@@ -1398,7 +1398,7 @@ async def init_db(migrate: bool = True, model_classes: List[Type[SQLModel]] = No
1398
1398
  if model_classes is None:
1399
1399
  model_classes = []
1400
1400
  # Get all model classes by inspecting the modules
1401
- for module_name, module in sys.modules.items():
1401
+ for module_name, module in list(sys.modules.items()):
1402
1402
  if hasattr(module, "__dict__"):
1403
1403
  for cls_name, cls in module.__dict__.items():
1404
1404
  if isinstance(cls, type) and issubclass(cls, SQLModel) and cls != SQLModel and cls != EasyModel:
@@ -0,0 +1,669 @@
1
+ """
2
+ Visualization utilities for EasyModel models.
3
+
4
+ This module provides tools to visualize EasyModel database schemas,
5
+ including tables, fields, relationships, and automatically generated virtual fields.
6
+ """
7
+
8
+ import inspect
9
+ import sys
10
+ from typing import Dict, List, Optional, Type, Set, Any, Union
11
+ from sqlmodel import SQLModel, Field
12
+ from sqlalchemy import inspect as sa_inspect
13
+
14
+ class ModelVisualizer:
15
+ """
16
+ Helper class for visualizing EasyModel database schemas.
17
+
18
+ This class provides methods to generate visual representations of the database schema,
19
+ including tables, fields, relationships, and automatically generated virtual fields.
20
+
21
+ Attributes:
22
+ model_registry (Dict[str, Type[SQLModel]]): Dictionary of registered models
23
+ title (str): Title for the Mermaid diagram
24
+ """
25
+
26
+ def __init__(self, title: str = "EasyModel Table Schemas"):
27
+ """
28
+ Initialize the ModelVisualizer.
29
+
30
+ Args:
31
+ title: Optional title for the Mermaid diagram
32
+ """
33
+ self.model_registry = {}
34
+ self.title = title
35
+ self._load_registered_models()
36
+
37
+ def set_title(self, title: str) -> None:
38
+ """
39
+ Set or update the title for the Mermaid diagram.
40
+
41
+ Args:
42
+ title: New title for the Mermaid diagram
43
+ """
44
+ self.title = title
45
+
46
+ def _load_registered_models(self):
47
+ """
48
+ Load all registered EasyModel models.
49
+ This should be called after init_db has been executed.
50
+ """
51
+ # First try to get models from auto_relationships registry
52
+ try:
53
+ from .auto_relationships import _model_registry
54
+ if _model_registry:
55
+ self.model_registry = _model_registry.copy()
56
+ return
57
+ except (ImportError, AttributeError):
58
+ pass
59
+
60
+ # Fallback: discover models from loaded modules
61
+ for module_name, module in sys.modules.items():
62
+ if hasattr(module, "__dict__"):
63
+ for cls_name, cls in module.__dict__.items():
64
+ if (inspect.isclass(cls) and
65
+ issubclass(cls, SQLModel) and
66
+ cls != SQLModel and
67
+ hasattr(cls, "__tablename__")):
68
+ self.model_registry[cls.__name__] = cls
69
+
70
+ def _get_foreign_keys(self, model_class: Type[SQLModel]) -> Dict[str, str]:
71
+ """
72
+ Extract foreign key information from a model class.
73
+
74
+ Args:
75
+ model_class: The SQLModel class to extract foreign keys from.
76
+
77
+ Returns:
78
+ Dictionary mapping field names to their foreign key references.
79
+ """
80
+ foreign_keys = {}
81
+
82
+ try:
83
+ # Check model annotations and __fields__ for foreign keys
84
+ if hasattr(model_class, "__annotations__"):
85
+ for field_name, field_type in model_class.__annotations__.items():
86
+ if hasattr(model_class, field_name):
87
+ field_value = getattr(model_class, field_name)
88
+ if hasattr(field_value, "foreign_key"):
89
+ if field_value.foreign_key:
90
+ foreign_keys[field_name] = field_value.foreign_key
91
+
92
+ # Check model attributes for Field instances with foreign_key
93
+ for attr_name in dir(model_class):
94
+ if attr_name.startswith("__") or attr_name in foreign_keys:
95
+ continue
96
+ try:
97
+ attr_value = getattr(model_class, attr_name)
98
+ if hasattr(attr_value, "foreign_key"):
99
+ if attr_value.foreign_key:
100
+ foreign_keys[attr_name] = attr_value.foreign_key
101
+ except (AttributeError, TypeError):
102
+ pass
103
+
104
+ # Try to infer foreign keys from field names ending with _id
105
+ if hasattr(model_class, "__fields__"):
106
+ for field_name in model_class.__fields__:
107
+ if field_name.endswith("_id") and field_name not in foreign_keys:
108
+ related_name = field_name[:-3] # Remove _id suffix
109
+ # Check if there's a model with this name
110
+ for model_name in self.model_registry:
111
+ if model_name.lower() == related_name.lower():
112
+ foreign_keys[field_name] = f"{model_name.lower()}.id"
113
+ break
114
+ except Exception as e:
115
+ # Log but don't re-raise to ensure visualization continues
116
+ print(f"Warning: Error getting foreign keys for {model_class.__name__}: {str(e)}")
117
+
118
+ return foreign_keys
119
+
120
+ def _get_virtual_relationship_fields(self, model_class: Type[SQLModel]) -> Dict[str, Dict[str, Any]]:
121
+ """
122
+ Get virtual relationship fields that are automatically generated.
123
+
124
+ Args:
125
+ model_class: The SQLModel class to extract virtual relationship fields from.
126
+
127
+ Returns:
128
+ Dictionary of virtual relationship fields info.
129
+ """
130
+ virtual_fields = {}
131
+ table_name = getattr(model_class, "__tablename__", model_class.__name__.lower())
132
+
133
+ try:
134
+ # Check for junction tables (tables with two foreign keys that form many-to-many)
135
+ foreign_keys = self._get_foreign_keys(model_class)
136
+
137
+ # Track models that have relations via this model's foreign keys
138
+ related_models = set()
139
+
140
+ # Determine if this is a junction table (has two foreign keys to different tables)
141
+ is_junction_table = False
142
+ if len(foreign_keys) >= 2:
143
+ referenced_tables = set()
144
+ for fk_target in foreign_keys.values():
145
+ if "." in fk_target:
146
+ referenced_tables.add(fk_target.split(".")[0])
147
+
148
+ is_junction_table = len(referenced_tables) >= 2
149
+
150
+ # For each foreign key, determine the virtual relationship field
151
+ for field_name, fk_target in foreign_keys.items():
152
+ if "." not in fk_target:
153
+ continue
154
+
155
+ target_table = fk_target.split(".")[0]
156
+
157
+ # Find the related model class
158
+ target_model = None
159
+ for name, cls in self.model_registry.items():
160
+ cls_table_name = getattr(cls, "__tablename__", name.lower())
161
+ if cls_table_name.lower() == target_table.lower():
162
+ target_model = name
163
+ related_models.add(name)
164
+ break
165
+
166
+ if not target_model:
167
+ continue
168
+
169
+ # The relationship field is typically named without the _id suffix
170
+ rel_name = field_name[:-3] if field_name.endswith("_id") else field_name
171
+
172
+ # Skip if this looks like a duplicate relationship
173
+ if rel_name in virtual_fields:
174
+ continue
175
+
176
+ # Add the virtual relationship field (singular form - one instance)
177
+ virtual_fields[rel_name] = {
178
+ "name": rel_name,
179
+ "type": target_model,
180
+ "is_list": False, # One-to-one or many-to-one
181
+ "related_model": target_model,
182
+ "is_virtual": True,
183
+ "is_required": False # Virtual fields are usually optional
184
+ }
185
+
186
+ # For junction tables, create the many-to-many virtual fields
187
+ if is_junction_table and len(related_models) >= 2:
188
+ # Don't add virtual fields to the junction table itself
189
+ return virtual_fields
190
+
191
+ # For each model, check if it's referenced in a junction table to create many-to-many virtuals
192
+ for junction_name, junction_class in self.model_registry.items():
193
+ junction_table_name = getattr(junction_class, "__tablename__", junction_name.lower())
194
+
195
+ # Skip if this is not a potential junction table
196
+ if junction_class == model_class: # Skip self
197
+ continue
198
+
199
+ junction_fks = self._get_foreign_keys(junction_class)
200
+ if len(junction_fks) < 2: # Not a potential junction
201
+ continue
202
+
203
+ # Check if this model is referenced by the potential junction table
204
+ this_model_referenced = False
205
+ other_referenced_models = set()
206
+
207
+ for fk_target in junction_fks.values():
208
+ if "." not in fk_target:
209
+ continue
210
+
211
+ target_table = fk_target.split(".")[0]
212
+
213
+ if target_table == table_name:
214
+ this_model_referenced = True
215
+ else:
216
+ # Find the model class for this target
217
+ for other_name, other_cls in self.model_registry.items():
218
+ other_table = getattr(other_cls, "__tablename__", other_name.lower())
219
+ if other_table == target_table:
220
+ other_referenced_models.add(other_name)
221
+ break
222
+
223
+ # If this is a junction table connecting this model to others
224
+ if this_model_referenced and other_referenced_models:
225
+ for other_model in other_referenced_models:
226
+ # Create a plural form of the model name for the relationship
227
+ # This is a very simple pluralization, might need to improve
228
+ other_model_lower = other_model.lower()
229
+ plural_name = f"{other_model_lower}s"
230
+
231
+ # Add the many-to-many virtual field (plural form)
232
+ virtual_fields[plural_name] = {
233
+ "name": plural_name,
234
+ "type": f"List[{other_model}]",
235
+ "is_list": True, # Many-to-many
236
+ "related_model": other_model,
237
+ "is_virtual": True,
238
+ "is_required": False
239
+ }
240
+
241
+ except Exception as e:
242
+ # Log but don't re-raise to ensure visualization continues
243
+ print(f"Warning: Error getting virtual fields for {model_class.__name__}: {str(e)}")
244
+
245
+ return virtual_fields
246
+
247
+ def _get_field_information(self, model_class: Type[SQLModel]) -> Dict[str, Dict[str, Any]]:
248
+ """
249
+ Extract field information from a model class.
250
+
251
+ Args:
252
+ model_class: The SQLModel class to extract field information from.
253
+
254
+ Returns:
255
+ Dictionary of field information with field names as keys.
256
+ """
257
+ fields = {}
258
+
259
+ try:
260
+ # Make sure the 'id' field is always included
261
+ fields["id"] = {
262
+ "name": "id",
263
+ "type": "int",
264
+ "is_primary": True,
265
+ "is_foreign_key": False,
266
+ "foreign_key_reference": None,
267
+ "is_virtual": False,
268
+ "is_required": False # Changed: primary keys are not considered "required" for the diagram
269
+ }
270
+
271
+ # For EasyModel tables, make sure created_at and updated_at are included
272
+ # These fields are automatically added by EasyModel but may not be in the model definitions
273
+ is_easy_model = False
274
+ # Check class hierarchy to determine if this is an EasyModel
275
+ for base in model_class.__mro__:
276
+ if base.__name__ == "EasyModel":
277
+ is_easy_model = True
278
+ break
279
+
280
+ if is_easy_model:
281
+ # Add created_at timestamp field
282
+ fields["created_at"] = {
283
+ "name": "created_at",
284
+ "type": "datetime",
285
+ "is_primary": False,
286
+ "is_foreign_key": False,
287
+ "foreign_key_reference": None,
288
+ "is_virtual": False,
289
+ "is_required": False # Not required for user input as handled automatically
290
+ }
291
+
292
+ # Add updated_at timestamp field
293
+ fields["updated_at"] = {
294
+ "name": "updated_at",
295
+ "type": "datetime",
296
+ "is_primary": False,
297
+ "is_foreign_key": False,
298
+ "foreign_key_reference": None,
299
+ "is_virtual": False,
300
+ "is_required": False # Not required for user input as handled automatically
301
+ }
302
+
303
+ # Get standard database fields
304
+ if hasattr(model_class, "__annotations__"):
305
+ for field_name, field_type in model_class.__annotations__.items():
306
+ # Skip private fields
307
+ if field_name.startswith("_"):
308
+ continue
309
+
310
+ # Get field type as string
311
+ type_str = getattr(field_type, "__name__", str(field_type))
312
+
313
+ # Check if field is optional
314
+ is_optional = False
315
+ if hasattr(field_type, "__origin__"):
316
+ if field_type.__origin__ is Union:
317
+ args = getattr(field_type, "__args__", [])
318
+ if type(None) in args:
319
+ is_optional = True
320
+ # Simplify type for optional fields (remove Union and NoneType)
321
+ other_types = [arg for arg in args if arg is not type(None)]
322
+ if len(other_types) == 1:
323
+ # If there's only one other type (common case like Optional[str])
324
+ type_str = getattr(other_types[0], "__name__", str(other_types[0]))
325
+ else:
326
+ # For more complex unions, just indicate it's a Union
327
+ type_str = "Union"
328
+
329
+ # Check if it's a primary key
330
+ is_primary = field_name == "id"
331
+
332
+ # Check if it's an auto-generated timestamp field
333
+ is_auto_timestamp = field_name in ["created_at", "updated_at"]
334
+
335
+ # Store field information
336
+ fields[field_name] = {
337
+ "name": field_name,
338
+ "type": type_str,
339
+ "is_primary": is_primary,
340
+ "is_foreign_key": False,
341
+ "foreign_key_reference": None,
342
+ "is_virtual": False,
343
+ "is_required": not is_optional and not is_primary and not is_auto_timestamp # Don't mark primary keys and auto timestamps as "required"
344
+ }
345
+
346
+ # Get foreign key information and update fields
347
+ foreign_keys = self._get_foreign_keys(model_class)
348
+ for field_name, fk_target in foreign_keys.items():
349
+ if field_name in fields:
350
+ fields[field_name]["is_foreign_key"] = True
351
+ fields[field_name]["foreign_key_reference"] = fk_target
352
+ else:
353
+ # Foreign key field wasn't in annotations, add it
354
+ fields[field_name] = {
355
+ "name": field_name,
356
+ "type": "int", # Most foreign keys are integers
357
+ "is_primary": False,
358
+ "is_foreign_key": True,
359
+ "foreign_key_reference": fk_target,
360
+ "is_virtual": False,
361
+ "is_required": field_name != "id" and field_name not in ["created_at", "updated_at"] # Don't mark auto timestamps as required
362
+ }
363
+
364
+ # Get virtual relationship fields
365
+ virtual_fields = self._get_virtual_relationship_fields(model_class)
366
+ for field_name, field_info in virtual_fields.items():
367
+ fields[field_name] = field_info
368
+
369
+ except Exception as e:
370
+ # Log but don't re-raise to ensure visualization continue
371
+ print(f"Warning: Error processing fields for {model_class.__name__}: {str(e)}")
372
+
373
+ return fields
374
+
375
+ def _generate_mermaid_content(self) -> str:
376
+ """
377
+ Generate the raw Mermaid ER diagram content without markdown code fences.
378
+ This is used internally by both mermaid() and mermaid_link() methods.
379
+
380
+ Returns:
381
+ String containing raw Mermaid ER diagram markup without markdown fences.
382
+ """
383
+ if not self.model_registry:
384
+ return f"---\ntitle: {self.title}\nconfig:\n layout: elk\n---\nerDiagram\n %% No models found. Run init_db first."
385
+
386
+ # Start with the title section
387
+ lines = [
388
+ "---",
389
+ f"title: {self.title}",
390
+ "config:",
391
+ " layout: elk",
392
+ "---",
393
+ "erDiagram"
394
+ ]
395
+
396
+ # Keep track of rendered relationships to avoid duplicates
397
+ rendered_relationships = set()
398
+ processed_models = set()
399
+
400
+ # Try to process all models, but continue even if some fail
401
+ for model_name, model_class in self.model_registry.items():
402
+ try:
403
+ table_name = getattr(model_class, "__tablename__", model_name.lower())
404
+ processed_models.add(model_name)
405
+
406
+ # Add entity definition
407
+ lines.append(f" {table_name} {{")
408
+
409
+ # Get fields for this model
410
+ fields = self._get_field_information(model_class)
411
+
412
+ # Separate timestamp fields to place them at the bottom
413
+ timestamp_fields = {}
414
+ if "created_at" in fields:
415
+ timestamp_fields["created_at"] = fields.pop("created_at")
416
+ if "updated_at" in fields:
417
+ timestamp_fields["updated_at"] = fields.pop("updated_at")
418
+
419
+ # Add regular fields first
420
+ for field_name, field_info in fields.items():
421
+ # Format type
422
+ field_type = self._simplify_type_for_mermaid(str(field_info["type"]))
423
+
424
+ # If it's a relationship, use the proper model name
425
+ if field_info.get("is_virtual", False) and field_info.get("related_model"):
426
+ related_model = field_info["related_model"]
427
+ if field_info.get("is_list", False):
428
+ # For list fields like 'tags', use the proper casing for model name
429
+ field_type = f"{related_model}[]"
430
+ else:
431
+ # For single object reference, use the model name directly
432
+ field_type = related_model
433
+
434
+ # Format attributes with proper Mermaid syntax
435
+ attrs_str = self._format_field_attributes(field_info)
436
+
437
+ # Add field
438
+ lines.append(f" {field_type} {field_name}{attrs_str}")
439
+
440
+ # Add timestamp fields at the bottom
441
+ for field_name in ["created_at", "updated_at"]:
442
+ if field_name in timestamp_fields:
443
+ field_info = timestamp_fields[field_name]
444
+ field_type = self._simplify_type_for_mermaid(str(field_info["type"]))
445
+ attrs_str = self._format_field_attributes(field_info)
446
+ lines.append(f" {field_type} {field_name}{attrs_str}")
447
+
448
+ # Close entity definition
449
+ lines.append(" }")
450
+
451
+ except Exception as e:
452
+ lines.append(f" %% Error defining {model_name}: {str(e)}")
453
+
454
+ # Add relationships between models
455
+ for model_name, model_class in self.model_registry.items():
456
+ try:
457
+ if model_name not in processed_models:
458
+ continue
459
+
460
+ table_name = getattr(model_class, "__tablename__", model_name.lower())
461
+
462
+ # Get fields for this model
463
+ fields = self._get_field_information(model_class)
464
+
465
+ # Add relationships based on foreign keys
466
+ for field_name, field_info in fields.items():
467
+ if field_info.get("is_foreign_key", False) and field_info.get("foreign_key_reference"):
468
+ # Parse the foreign key reference to get the target table
469
+ fk_ref = field_info["foreign_key_reference"]
470
+ target_table = fk_ref.split(".")[0] if "." in fk_ref else fk_ref
471
+
472
+ # Create relationship ID to avoid duplicates
473
+ rel_id = f"{table_name}_{target_table}_{field_name}"
474
+ if rel_id in rendered_relationships:
475
+ continue
476
+
477
+ # Add the relationship
478
+ lines.append(f" {table_name} ||--o{{ {target_table} : \"{field_name}\"")
479
+ rendered_relationships.add(rel_id)
480
+
481
+ # Add many-to-many relationships
482
+ # Check if this model might be a junction table
483
+ if len(fields) >= 3: # id + at least 2 foreign keys
484
+ foreign_key_fields = [f for f in fields.values() if f.get("is_foreign_key", False)]
485
+
486
+ if len(foreign_key_fields) >= 2:
487
+ # This might be a junction table, try to render special M:N relationship
488
+ for i, fk1 in enumerate(foreign_key_fields):
489
+ for fk2 in foreign_key_fields[i+1:]:
490
+ # Skip if either field doesn't have a foreign key reference
491
+ if not fk1.get("foreign_key_reference") or not fk2.get("foreign_key_reference"):
492
+ continue
493
+
494
+ # Get the target tables
495
+ target1 = fk1["foreign_key_reference"].split(".")[0]
496
+ target2 = fk2["foreign_key_reference"].split(".")[0]
497
+
498
+ # Skip self-references or duplicates
499
+ if target1 == target2:
500
+ continue
501
+
502
+ # Create relationship IDs
503
+ rel_id1 = f"{target1}_{target2}_m2m"
504
+ rel_id2 = f"{target2}_{target1}_m2m"
505
+
506
+ if rel_id1 in rendered_relationships or rel_id2 in rendered_relationships:
507
+ continue
508
+
509
+ # Add the many-to-many relationship directly between the end entities
510
+ lines.append(f" {target1} }}o--o{{ {target2} : \"many-to-many\"")
511
+ rendered_relationships.add(rel_id1)
512
+
513
+ except Exception as e:
514
+ lines.append(f" %% Error processing relationships for {model_name}: {str(e)}")
515
+
516
+ return "\n".join(lines)
517
+
518
+ def _simplify_type_for_mermaid(self, type_str: str) -> str:
519
+ """
520
+ Simplify a Python type string for Mermaid ER diagram display.
521
+
522
+ Args:
523
+ type_str: Python type string to simplify
524
+
525
+ Returns:
526
+ A simplified type string suitable for Mermaid diagrams
527
+ """
528
+ # Remove common Python type prefixes
529
+ simplified = type_str.replace("typing.", "").replace("__main__.", "")
530
+
531
+ # Handle common container types
532
+ if simplified.startswith("List["):
533
+ inner_type = simplified[5:-1] # Extract the type inside List[]
534
+ # Preserve proper casing for model names
535
+ return f"{inner_type}[]"
536
+
537
+ # Handle other complex types that might confuse Mermaid
538
+ if "[" in simplified or "Union" in simplified or "Optional" in simplified:
539
+ # For complex types, just return a more generic type name
540
+ if "str" in simplified:
541
+ return "string"
542
+ elif "int" in simplified or "float" in simplified:
543
+ return "number"
544
+ elif "bool" in simplified:
545
+ return "boolean"
546
+ elif "dict" in simplified or "Dict" in simplified:
547
+ return "object"
548
+ else:
549
+ return "any"
550
+
551
+ # Simple type mapping to more Mermaid-friendly types
552
+ type_map = {
553
+ "str": "string",
554
+ "int": "number",
555
+ "float": "number",
556
+ "bool": "boolean",
557
+ "dict": "object",
558
+ "Dict": "object",
559
+ "datetime": "datetime",
560
+ "date": "date",
561
+ "time": "time",
562
+ "bytes": "binary",
563
+ "bytearray": "binary"
564
+ }
565
+
566
+ return type_map.get(simplified, simplified)
567
+
568
+ def _get_model_name_for_table(self, table_name: str) -> str:
569
+ """
570
+ Get the proper cased model name for a table name.
571
+
572
+ Args:
573
+ table_name: The table name to find the model name for
574
+
575
+ Returns:
576
+ The properly cased model name
577
+ """
578
+ for model_name, model_class in self.model_registry.items():
579
+ if getattr(model_class, "__tablename__", model_name.lower()) == table_name:
580
+ return model_name
581
+ return table_name # Fallback to table name if no model found
582
+
583
+ def _format_field_attributes(self, field_info: Dict[str, Any]) -> str:
584
+ """
585
+ Format field attributes according to Mermaid syntax.
586
+ Only the last attribute should have double quotes, and attributes are separated by spaces.
587
+
588
+ Args:
589
+ field_info: Dictionary with field information
590
+
591
+ Returns:
592
+ String with properly formatted attributes for the Mermaid diagram
593
+ """
594
+ attrs = []
595
+
596
+ # Order is important: PK, FK, and then other attributes
597
+ if field_info.get("is_primary", False):
598
+ attrs.append("PK")
599
+ if field_info.get("is_foreign_key", False):
600
+ attrs.append("FK")
601
+
602
+ # The comment attribute (should be last and in quotes)
603
+ comment = None
604
+ if field_info.get("is_virtual", False):
605
+ comment = "virtual"
606
+ elif field_info.get("is_required", False):
607
+ comment = "required"
608
+
609
+ # Format the attribute string
610
+ if not attrs and not comment:
611
+ return ""
612
+
613
+ result = ""
614
+ if attrs:
615
+ result = " " + " ".join(attrs)
616
+
617
+ if comment:
618
+ result += f' "{comment}"'
619
+
620
+ return result
621
+
622
+ def mermaid(self) -> str:
623
+ """
624
+ Generate a Mermaid ER diagram for all registered models.
625
+
626
+ Returns:
627
+ String containing Mermaid ER diagram markup in markdown format.
628
+ """
629
+ # Get the raw Mermaid content
630
+ content = self._generate_mermaid_content()
631
+
632
+ # Wrap in markdown code fences
633
+ return f"```mermaid\n{content}\n```"
634
+
635
+ def mermaid_link(self) -> str:
636
+ """
637
+ Generate a Mermaid Live Editor link for the ER diagram.
638
+
639
+ Returns:
640
+ URL string that opens the diagram in Mermaid Live Editor.
641
+ """
642
+ # Code to handle mermaid.live base64 links
643
+ import base64, json, zlib
644
+
645
+ def js_btoa(data):
646
+ return base64.b64encode(data)
647
+
648
+ def pako_deflate(data):
649
+ compress = zlib.compressobj(9, zlib.DEFLATED, 15, 8, zlib.Z_DEFAULT_STRATEGY)
650
+ compressed_data = compress.compress(data)
651
+ compressed_data += compress.flush()
652
+ return compressed_data
653
+
654
+ def gen_pako_link(graph_markdown: str):
655
+ jGraph = {"code": graph_markdown, "mermaid": {"theme": "default"}}
656
+ byte_str = json.dumps(jGraph).encode('utf-8')
657
+ deflated = pako_deflate(byte_str)
658
+ d_encode = js_btoa(deflated)
659
+ link = 'https://mermaid.live/edit#pako:' + d_encode.decode('ascii')
660
+ return link
661
+
662
+ # Get the raw Mermaid content directly (without markdown fences)
663
+ mermaid_content = self._generate_mermaid_content()
664
+
665
+ # Generate the link
666
+ return gen_pako_link(mermaid_content)
667
+
668
+ # Alias for backward compatibility
669
+ generate_mermaid_er_diagram = mermaid
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: async-easy-model
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: A simplified SQLModel-based ORM for async database operations
5
5
  Home-page: https://github.com/puntorigen/easy-model
6
6
  Author: Pablo Schaffner
@@ -32,6 +32,7 @@ Dynamic: description
32
32
  Dynamic: description-content-type
33
33
  Dynamic: home-page
34
34
  Dynamic: keywords
35
+ Dynamic: license-file
35
36
  Dynamic: requires-dist
36
37
  Dynamic: requires-python
37
38
  Dynamic: summary
@@ -59,6 +60,7 @@ A simplified SQLModel-based ORM for async database operations in Python. async-e
59
60
  - 📝 Type hints for better IDE support
60
61
  - 🕒 Automatic `id`, `created_at` and `updated_at` fields provided by default
61
62
  - 🔄 Automatic schema migrations for evolving database models
63
+ - 📊 Visualization of database schema using Mermaid ER diagrams
62
64
 
63
65
  ## Installation
64
66
 
@@ -250,6 +252,89 @@ deleted_count = await User.delete({"is_active": False})
250
252
  await Post.delete({"user": {"username": "john_doe"}, "is_published": False})
251
253
  ```
252
254
 
255
+ ## Database Schema Visualization
256
+
257
+ The package includes a `ModelVisualizer` class that makes it easy to generate Entity-Relationship (ER) diagrams for your database models using Mermaid syntax.
258
+
259
+ ```python
260
+ from async_easy_model import EasyModel, init_db, db_config, ModelVisualizer
261
+
262
+ # Initialize your models and database
263
+ await init_db()
264
+
265
+ # Create a visualizer
266
+ visualizer = ModelVisualizer()
267
+
268
+ # Generate a Mermaid ER diagram
269
+ er_diagram = visualizer.mermaid()
270
+ print(er_diagram)
271
+
272
+ # Generate a shareable link to view the diagram online
273
+ er_link = visualizer.mermaid_link()
274
+ print(er_link)
275
+
276
+ # Customize the diagram title
277
+ visualizer.set_title("My Project Database Schema")
278
+ custom_diagram = visualizer.mermaid()
279
+ ```
280
+
281
+ ### Example Mermaid ER Diagram Output
282
+
283
+ ```mermaid
284
+ ---
285
+ title: EasyModel Table Schemas
286
+ config:
287
+ layout: elk
288
+ ---
289
+ erDiagram
290
+ author {
291
+ number id PK
292
+ string name "required"
293
+ string email
294
+ }
295
+ book {
296
+ number id PK
297
+ string title "required"
298
+ number author_id FK
299
+ string isbn
300
+ number published_year
301
+ author author "virtual"
302
+ tag[] tags "virtual"
303
+ }
304
+ tag {
305
+ number id PK
306
+ string name "required"
307
+ book[] books "virtual"
308
+ }
309
+ book_tag {
310
+ number id PK
311
+ number book_id FK "required"
312
+ number tag_id FK "required"
313
+ book book "virtual"
314
+ tag tag "virtual"
315
+ }
316
+ review {
317
+ number id PK
318
+ number book_id FK "required"
319
+ number rating "required"
320
+ string comment
321
+ string reviewer_name "required"
322
+ book book "virtual"
323
+ }
324
+ book ||--o{ author : "author_id"
325
+ book_tag ||--o{ book : "book_id"
326
+ book_tag ||--o{ tag : "tag_id"
327
+ book }o--o{ tag : "many-to-many"
328
+ review ||--o{ book : "book_id"
329
+ ```
330
+
331
+ The diagram automatically:
332
+ - Shows all tables with their fields and data types
333
+ - Identifies primary keys (PK) and foreign keys (FK)
334
+ - Shows required fields and virtual relationships
335
+ - Visualizes relationships between tables with proper cardinality
336
+ - Properly handles many-to-many relationships
337
+
253
338
  ## Convenient Query Methods
254
339
 
255
340
  async-easy-model provides simplified methods for common query patterns:
@@ -0,0 +1,11 @@
1
+ async_easy_model/__init__.py,sha256=5OzRFnNcCv5QAkDM7Gkt9jW4K1BIxXoYy_KbCZXZWnw,1739
2
+ async_easy_model/auto_relationships.py,sha256=V2LAzNi7y-keFk4C_m-byVRM-k_7nL5HEy9Ig3nEdq8,27756
3
+ async_easy_model/migrations.py,sha256=rYDGCGlruSugAmPfdIF2-uhyG6UvC_2qbF3BXJ084qI,17803
4
+ async_easy_model/model.py,sha256=f0eMcIaDOz9s01A4jpQ-T_VpCgFt67JF1puDYuhpdv4,63290
5
+ async_easy_model/relationships.py,sha256=vR5BsJpGaDcecCcNlg9-ouZfxFXFQv5kOyiXhKp_T7A,3286
6
+ async_easy_model/visualization.py,sha256=RVCdc8j3uUQe-zy3jXju_yhA13qJ8KWVbQ5fQyjyqkA,29973
7
+ async_easy_model-0.2.6.dist-info/licenses/LICENSE,sha256=uwDkl6oHbRltW7vYKNc4doJyhtwhyrSNFFlPpKATwLE,1072
8
+ async_easy_model-0.2.6.dist-info/METADATA,sha256=JRhh3mi73bMeLWAA83G3vkQl9O0avDkmasjxVVO8nFw,12888
9
+ async_easy_model-0.2.6.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
10
+ async_easy_model-0.2.6.dist-info/top_level.txt,sha256=e5_47sGmJnyxz2msfwU6C316EqmrSd9RGIYwZyWx68E,17
11
+ async_easy_model-0.2.6.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (76.0.0)
2
+ Generator: setuptools (77.0.3)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,10 +0,0 @@
1
- async_easy_model/__init__.py,sha256=qtmIxVNPohdcmh0KmQT1TArYRQRtA9aVj8RAa5qrApc,1642
2
- async_easy_model/auto_relationships.py,sha256=V2LAzNi7y-keFk4C_m-byVRM-k_7nL5HEy9Ig3nEdq8,27756
3
- async_easy_model/migrations.py,sha256=rYDGCGlruSugAmPfdIF2-uhyG6UvC_2qbF3BXJ084qI,17803
4
- async_easy_model/model.py,sha256=Vq6NTUThuEKy_CVLAb6Dy5pcDjbBr49qgamTQj5GtZ0,63284
5
- async_easy_model/relationships.py,sha256=vR5BsJpGaDcecCcNlg9-ouZfxFXFQv5kOyiXhKp_T7A,3286
6
- async_easy_model-0.2.4.dist-info/LICENSE,sha256=uwDkl6oHbRltW7vYKNc4doJyhtwhyrSNFFlPpKATwLE,1072
7
- async_easy_model-0.2.4.dist-info/METADATA,sha256=kV2oSzYEJ2ac1ScXrWmTjr4EJtO8-e-LQawsBSykr5k,10720
8
- async_easy_model-0.2.4.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
9
- async_easy_model-0.2.4.dist-info/top_level.txt,sha256=e5_47sGmJnyxz2msfwU6C316EqmrSd9RGIYwZyWx68E,17
10
- async_easy_model-0.2.4.dist-info/RECORD,,