async-easy-model 0.2.1__py3-none-any.whl → 0.2.3__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,7 +6,7 @@ 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.1.12"
9
+ __version__ = "0.2.3"
10
10
  __all__ = ["EasyModel", "init_db", "db_config", "Field", "Relationship", "Relation", "enable_auto_relationships", "disable_auto_relationships", "process_auto_relationships", "MigrationManager", "check_and_migrate_models"]
11
11
 
12
12
  # Create a more user-friendly Relationship function
async_easy_model/model.py CHANGED
@@ -411,6 +411,13 @@ class EasyModel(SQLModel):
411
411
  # Process relationships first
412
412
  processed_item = await cls._process_relationships_for_insert(session, item)
413
413
 
414
+ # Extract special _related_* fields for post-processing
415
+ related_fields = {}
416
+ for key in list(processed_item.keys()):
417
+ if key.startswith("_related_"):
418
+ rel_name = key[9:] # Remove "_related_" prefix
419
+ related_fields[rel_name] = processed_item.pop(key)
420
+
414
421
  # Check if a record with unique constraints already exists
415
422
  unique_fields = cls._get_unique_fields()
416
423
  existing_obj = None
@@ -433,12 +440,33 @@ class EasyModel(SQLModel):
433
440
  for key, value in processed_item.items():
434
441
  if key != 'id': # Don't update ID
435
442
  setattr(existing_obj, key, value)
436
- objects.append(existing_obj)
443
+ obj = existing_obj
437
444
  else:
438
445
  # Create new object
439
446
  obj = cls(**processed_item)
440
447
  session.add(obj)
441
- objects.append(obj)
448
+
449
+ # Flush to get the ID for this object
450
+ await session.flush()
451
+
452
+ # Now handle any one-to-many relationships
453
+ for rel_name, related_objects in related_fields.items():
454
+ # Check if the relationship attribute exists in the class (not the instance)
455
+ if hasattr(cls, rel_name):
456
+ # Get the relationship attribute from the class
457
+ rel_attr = getattr(cls, rel_name)
458
+
459
+ # Check if it's a SQLAlchemy relationship
460
+ if hasattr(rel_attr, 'property') and hasattr(rel_attr.property, 'back_populates'):
461
+ back_attr = rel_attr.property.back_populates
462
+
463
+ # For each related object, set the back reference to this object
464
+ for related_obj in related_objects:
465
+ setattr(related_obj, back_attr, obj)
466
+ # Make sure the related object is in the session
467
+ session.add(related_obj)
468
+
469
+ objects.append(obj)
442
470
  except Exception as e:
443
471
  logging.error(f"Error inserting record: {e}")
444
472
  await session.rollback()
@@ -465,6 +493,13 @@ class EasyModel(SQLModel):
465
493
  # Process relationships first
466
494
  processed_data = await cls._process_relationships_for_insert(session, data)
467
495
 
496
+ # Extract special _related_* fields for post-processing
497
+ related_fields = {}
498
+ for key in list(processed_data.keys()):
499
+ if key.startswith("_related_"):
500
+ rel_name = key[9:] # Remove "_related_" prefix
501
+ related_fields[rel_name] = processed_data.pop(key)
502
+
468
503
  # Check if a record with unique constraints already exists
469
504
  unique_fields = cls._get_unique_fields()
470
505
  existing_obj = None
@@ -494,6 +529,24 @@ class EasyModel(SQLModel):
494
529
  session.add(obj)
495
530
 
496
531
  await session.flush() # Flush to get the ID
532
+
533
+ # Now handle any one-to-many relationships
534
+ for rel_name, related_objects in related_fields.items():
535
+ # Check if the relationship attribute exists in the class (not the instance)
536
+ if hasattr(cls, rel_name):
537
+ # Get the relationship attribute from the class
538
+ rel_attr = getattr(cls, rel_name)
539
+
540
+ # Check if it's a SQLAlchemy relationship
541
+ if hasattr(rel_attr, 'property') and hasattr(rel_attr.property, 'back_populates'):
542
+ back_attr = rel_attr.property.back_populates
543
+
544
+ # For each related object, set the back reference to this object
545
+ for related_obj in related_objects:
546
+ setattr(related_obj, back_attr, obj)
547
+ # Make sure the related object is in the session
548
+ session.add(related_obj)
549
+
497
550
  await session.commit()
498
551
 
499
552
  if include_relationships:
@@ -510,7 +563,7 @@ class EasyModel(SQLModel):
510
563
  logging.error(f"Error inserting record: {e}")
511
564
  await session.rollback()
512
565
  raise
513
-
566
+
514
567
  @classmethod
515
568
  def _get_unique_fields(cls) -> List[str]:
516
569
  """
@@ -537,6 +590,15 @@ class EasyModel(SQLModel):
537
590
  "quantity": 2
538
591
  })
539
592
 
593
+ It also handles lists of related objects for one-to-many relationships:
594
+ publisher = await Publisher.insert({
595
+ "name": "Example Publisher",
596
+ "authors": [
597
+ {"name": "Author 1", "email": "author1@example.com"},
598
+ {"name": "Author 2", "email": "author2@example.com"}
599
+ ]
600
+ })
601
+
540
602
  For each nested object:
541
603
  1. Find the target model class
542
604
  2. Check if an object with the same unique fields already exists
@@ -566,8 +628,8 @@ class EasyModel(SQLModel):
566
628
 
567
629
  # Handle nested relationship objects
568
630
  for key, value in data.items():
569
- # Skip if the value is not a dictionary
570
- if not isinstance(value, dict):
631
+ # Skip if None
632
+ if value is None:
571
633
  continue
572
634
 
573
635
  # Check if this is a relationship field (either by name or derived from foreign key)
@@ -621,105 +683,164 @@ class EasyModel(SQLModel):
621
683
  logging.warning(f"Could not find related model for {key} in {cls.__name__}")
622
684
  continue
623
685
 
624
- # Look for unique fields in the related model to use for searching
625
- unique_fields = []
626
- for field_name, field_info in related_model.__fields__.items():
627
- if (hasattr(field_info, "field_info") and
628
- field_info.field_info.extra.get('unique', False)):
629
- unique_fields.append(field_name)
630
-
631
- # Create a search dictionary using unique fields
632
- search_dict = {}
633
- for field in unique_fields:
634
- if field in value and value[field] is not None:
635
- search_dict[field] = value[field]
636
-
637
- # If no unique fields found but ID is provided, use it
638
- if not search_dict and 'id' in value and value['id']:
639
- search_dict = {'id': value['id']}
640
-
641
- # Special case for products without uniqueness constraints
642
- if not search_dict and related_model.__tablename__ == 'products' and 'name' in value:
643
- search_dict = {'name': value['name']}
644
-
645
- # Try to find an existing record
646
- related_obj = None
647
- if search_dict:
648
- logging.info(f"Searching for existing {related_model.__name__} with {search_dict}")
686
+ # Check if the value is a list (one-to-many) or dict (one-to-one)
687
+ if isinstance(value, list):
688
+ # Handle one-to-many relationship (list of dictionaries)
689
+ related_objects = []
649
690
 
650
- try:
651
- # Create a more appropriate search query based on unique fields
652
- existing_stmt = select(related_model)
653
- for field, field_value in search_dict.items():
654
- existing_stmt = existing_stmt.where(getattr(related_model, field) == field_value)
655
-
656
- existing_result = await session.execute(existing_stmt)
657
- related_obj = existing_result.scalars().first()
658
-
659
- if related_obj:
660
- logging.info(f"Found existing {related_model.__name__} with ID: {related_obj.id}")
661
- except Exception as e:
662
- logging.error(f"Error finding existing record: {e}")
663
-
664
- if related_obj:
665
- # Update the existing record with any non-unique field values
666
- for attr, attr_val in value.items():
667
- # Skip ID field
668
- if attr == 'id':
669
- continue
670
-
671
- # Skip unique fields with different values to avoid constraint violations
672
- if attr in unique_fields and getattr(related_obj, attr) != attr_val:
691
+ for item in value:
692
+ if not isinstance(item, dict):
693
+ logging.warning(f"Skipping non-dict item in list for {key}")
673
694
  continue
674
695
 
675
- # Update non-unique fields
676
- current_val = getattr(related_obj, attr, None)
677
- if current_val != attr_val:
678
- setattr(related_obj, attr, attr_val)
696
+ related_obj = await cls._process_single_relationship_item(
697
+ session, related_model, item
698
+ )
699
+ if related_obj:
700
+ related_objects.append(related_obj)
679
701
 
680
- # Add the updated object to the session
681
- session.add(related_obj)
682
- logging.info(f"Reusing existing {related_model.__name__} with ID: {related_obj.id}")
683
- else:
684
- # Create a new record
685
- logging.info(f"Creating new {related_model.__name__} for {key}")
686
- related_obj = related_model(**value)
687
- session.add(related_obj)
702
+ # For one-to-many, we need to keep a list of related objects to be attached later
703
+ # We'll store them in a special field that will be removed before creating the model
704
+ result[f"_related_{key}"] = related_objects
705
+
706
+ # Remove the original field from the result
707
+ if key in result:
708
+ del result[key]
688
709
 
689
- # Ensure the object has an ID by flushing
690
- try:
691
- await session.flush()
692
- except Exception as e:
693
- logging.error(f"Error flushing session for {related_model.__name__}: {e}")
710
+ elif isinstance(value, dict):
711
+ # Handle one-to-one relationship (single dictionary)
712
+ related_obj = await cls._process_single_relationship_item(
713
+ session, related_model, value
714
+ )
694
715
 
695
- # If there was a uniqueness error, try again to find the existing record
696
- if "UNIQUE constraint failed" in str(e):
697
- logging.info(f"UNIQUE constraint failed, trying to find existing record again")
716
+ if related_obj:
717
+ # Update the result with the foreign key ID
718
+ foreign_key_name = f"{key}_id"
719
+ result[foreign_key_name] = related_obj.id
698
720
 
699
- # Try to find by any field provided in the search_dict
700
- existing_stmt = select(related_model)
701
- for field, field_value in search_dict.items():
702
- existing_stmt = existing_stmt.where(getattr(related_model, field) == field_value)
703
-
704
- # Execute the search query
705
- existing_result = await session.execute(existing_stmt)
706
- related_obj = existing_result.scalars().first()
707
-
708
- if not related_obj:
709
- # We couldn't find an existing record, re-raise the exception
710
- raise
711
-
712
- logging.info(f"Found existing {related_model.__name__} with ID: {related_obj.id} after constraint error")
721
+ # Remove the relationship dictionary from the result
722
+ if key in result:
723
+ del result[key]
724
+
725
+ return result
726
+
727
+ @classmethod
728
+ async def _process_single_relationship_item(cls, session: AsyncSession, related_model: Type, item_data: Dict[str, Any]) -> Optional[Any]:
729
+ """
730
+ Process a single relationship item (dictionary).
731
+
732
+ This helper method is used by _process_relationships_for_insert to handle
733
+ both singular relationship objects and items within lists of relationships.
734
+
735
+ Args:
736
+ session: The database session to use
737
+ related_model: The related model class
738
+ item_data: Dictionary with field values for the related object
739
+
740
+ Returns:
741
+ The created or found related object, or None if processing failed
742
+ """
743
+ # Look for unique fields in the related model to use for searching
744
+ unique_fields = []
745
+ for field_name, field_info in related_model.__fields__.items():
746
+ if (hasattr(field_info, "field_info") and
747
+ field_info.field_info.extra.get('unique', False)):
748
+ unique_fields.append(field_name)
749
+
750
+ # Create a search dictionary using unique fields
751
+ search_dict = {}
752
+ for field in unique_fields:
753
+ if field in item_data and item_data[field] is not None:
754
+ search_dict[field] = item_data[field]
755
+
756
+ # If no unique fields found but ID is provided, use it
757
+ if not search_dict and 'id' in item_data and item_data['id']:
758
+ search_dict = {'id': item_data['id']}
759
+
760
+ # Special case for products without uniqueness constraints
761
+ if not search_dict and related_model.__tablename__ == 'products' and 'name' in item_data:
762
+ search_dict = {'name': item_data['name']}
763
+
764
+ # Try to find an existing record
765
+ related_obj = None
766
+ if search_dict:
767
+ logging.info(f"Searching for existing {related_model.__name__} with {search_dict}")
768
+
769
+ try:
770
+ # Create a more appropriate search query based on unique fields
771
+ existing_stmt = select(related_model)
772
+ for field, field_value in search_dict.items():
773
+ existing_stmt = existing_stmt.where(getattr(related_model, field) == field_value)
713
774
 
714
- # Update the result with the foreign key ID
715
- foreign_key_name = f"{key}_id"
716
- result[foreign_key_name] = related_obj.id
775
+ existing_result = await session.execute(existing_stmt)
776
+ related_obj = existing_result.scalars().first()
717
777
 
718
- # Remove the relationship dictionary from the result
719
- if key in result:
720
- del result[key]
778
+ if related_obj:
779
+ logging.info(f"Found existing {related_model.__name__} with ID: {related_obj.id}")
780
+ except Exception as e:
781
+ logging.error(f"Error finding existing record: {e}")
721
782
 
722
- return result
783
+ if related_obj:
784
+ # Update the existing record with any non-unique field values
785
+ for attr, attr_val in item_data.items():
786
+ # Skip ID field
787
+ if attr == 'id':
788
+ continue
789
+
790
+ # Skip unique fields with different values to avoid constraint violations
791
+ if attr in unique_fields and getattr(related_obj, attr) != attr_val:
792
+ continue
793
+
794
+ # Update non-unique fields
795
+ current_val = getattr(related_obj, attr, None)
796
+ if current_val != attr_val:
797
+ setattr(related_obj, attr, attr_val)
798
+
799
+ # Add the updated object to the session
800
+ session.add(related_obj)
801
+ logging.info(f"Reusing existing {related_model.__name__} with ID: {related_obj.id}")
802
+ else:
803
+ # Create a new record
804
+ logging.info(f"Creating new {related_model.__name__}")
805
+
806
+ # Process nested relationships in this item first
807
+ if hasattr(related_model, '_process_relationships_for_insert'):
808
+ # This is a recursive call to process nested relationships
809
+ processed_item_data = await related_model._process_relationships_for_insert(
810
+ session, item_data
811
+ )
812
+ else:
813
+ processed_item_data = item_data
814
+
815
+ related_obj = related_model(**processed_item_data)
816
+ session.add(related_obj)
817
+
818
+ # Ensure the object has an ID by flushing
819
+ try:
820
+ await session.flush()
821
+ except Exception as e:
822
+ logging.error(f"Error flushing session for {related_model.__name__}: {e}")
823
+
824
+ # If there was a uniqueness error, try again to find the existing record
825
+ if "UNIQUE constraint failed" in str(e):
826
+ logging.info(f"UNIQUE constraint failed, trying to find existing record again")
827
+
828
+ # Try to find by any field provided in the search_dict
829
+ existing_stmt = select(related_model)
830
+ for field, field_value in search_dict.items():
831
+ existing_stmt = existing_stmt.where(getattr(related_model, field) == field_value)
832
+
833
+ # Execute the search query
834
+ existing_result = await session.execute(existing_stmt)
835
+ related_obj = existing_result.scalars().first()
836
+
837
+ if not related_obj:
838
+ # We couldn't find an existing record, re-raise the exception
839
+ raise
840
+
841
+ logging.info(f"Found existing {related_model.__name__} with ID: {related_obj.id} after constraint error")
842
+
843
+ return related_obj
723
844
 
724
845
  @classmethod
725
846
  async def update(cls: Type[T], data: Dict[str, Any], criteria: Dict[str, Any], include_relationships: bool = True) -> Optional[T]:
@@ -861,60 +982,6 @@ class EasyModel(SQLModel):
861
982
  await session.rollback()
862
983
  raise
863
984
 
864
- @classmethod
865
- async def create_with_related(
866
- cls: Type[T],
867
- data: Dict[str, Any],
868
- related_data: Dict[str, List[Dict[str, Any]]] = None
869
- ) -> T:
870
- """
871
- Create a model instance with related objects in a single transaction.
872
-
873
- Args:
874
- data: Dictionary of field values for the main model
875
- related_data: Dictionary mapping relationship names to lists of data dictionaries
876
- for creating related objects
877
-
878
- Returns:
879
- The created model instance with relationships loaded
880
- """
881
- if related_data is None:
882
- related_data = {}
883
-
884
- async with cls.get_session() as session:
885
- # Create the main object
886
- obj = cls(**data)
887
- session.add(obj)
888
- await session.flush() # Flush to get the ID
889
-
890
- # Create related objects
891
- for rel_name, items_data in related_data.items():
892
- if not hasattr(cls, rel_name):
893
- continue
894
-
895
- rel_attr = getattr(cls, rel_name)
896
- if not hasattr(rel_attr, "property"):
897
- continue
898
-
899
- # Get the related model class and the back reference attribute
900
- related_model = rel_attr.property.mapper.class_
901
- back_populates = getattr(rel_attr.property, "back_populates", None)
902
-
903
- # Create each related object
904
- for item_data in items_data:
905
- # Set the back reference if it exists
906
- if back_populates:
907
- item_data[back_populates] = obj
908
-
909
- related_obj = related_model(**item_data)
910
- session.add(related_obj)
911
-
912
- await session.commit()
913
-
914
- # Refresh with relationships
915
- await session.refresh(obj, attribute_names=list(related_data.keys()))
916
- return obj
917
-
918
985
  def to_dict(self, include_relationships: bool = True, max_depth: int = 4) -> Dict[str, Any]:
919
986
  """
920
987
  Convert the model instance to a dictionary.
@@ -997,7 +1064,7 @@ class EasyModel(SQLModel):
997
1064
  Retrieve record(s) by matching attribute values.
998
1065
 
999
1066
  Args:
1000
- criteria: Dictionary of field values to filter by (field=value)
1067
+ criteria: Dictionary of search criteria
1001
1068
  all: If True, return all matching records, otherwise return only the first one
1002
1069
  first: If True, return only the first record (equivalent to all=False)
1003
1070
  include_relationships: If True, eagerly load all relationships
@@ -1110,6 +1177,37 @@ class EasyModel(SQLModel):
1110
1177
  new_record = await cls.insert(data)
1111
1178
  return new_record, True
1112
1179
 
1180
+ @classmethod
1181
+ async def insert_with_related(
1182
+ cls: Type[T],
1183
+ data: Dict[str, Any],
1184
+ related_data: Dict[str, List[Dict[str, Any]]] = None
1185
+ ) -> T:
1186
+ """
1187
+ Create a model instance with related objects in a single transaction.
1188
+
1189
+ Args:
1190
+ data: Dictionary of field values for the main model
1191
+ related_data: Dictionary mapping relationship names to lists of data dictionaries
1192
+ for creating related objects
1193
+
1194
+ Returns:
1195
+ The created model instance with relationships loaded
1196
+ """
1197
+ if related_data is None:
1198
+ related_data = {}
1199
+
1200
+ # Create a copy of data for modification
1201
+ insert_data = data.copy()
1202
+
1203
+ # Add relationship fields to the data
1204
+ for rel_name, items_data in related_data.items():
1205
+ if items_data:
1206
+ insert_data[rel_name] = items_data
1207
+
1208
+ # Use the enhanced insert method to handle all relationships
1209
+ return await cls.insert(insert_data, include_relationships=True)
1210
+
1113
1211
  # Register an event listener to update 'updated_at' on instance modifications.
1114
1212
  @event.listens_for(Session, "before_flush")
1115
1213
  def _update_updated_at(session, flush_context, instances):
@@ -0,0 +1,362 @@
1
+ Metadata-Version: 2.2
2
+ Name: async-easy-model
3
+ Version: 0.2.3
4
+ Summary: A simplified SQLModel-based ORM for async database operations
5
+ Home-page: https://github.com/puntorigen/easy-model
6
+ Author: Pablo Schaffner
7
+ Author-email: pablo@puntorigen.com
8
+ Keywords: orm,sqlmodel,database,async,postgresql,sqlite
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.7
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Requires-Python: >=3.7
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: sqlmodel>=0.0.8
23
+ Requires-Dist: sqlalchemy>=2.0.0
24
+ Requires-Dist: asyncpg>=0.25.0
25
+ Requires-Dist: aiosqlite>=0.19.0
26
+ Requires-Dist: greenlet>=3.1.1
27
+ Requires-Dist: inflection>=0.5.1
28
+ Dynamic: author
29
+ Dynamic: author-email
30
+ Dynamic: classifier
31
+ Dynamic: description
32
+ Dynamic: description-content-type
33
+ Dynamic: home-page
34
+ Dynamic: keywords
35
+ Dynamic: requires-dist
36
+ Dynamic: requires-python
37
+ Dynamic: summary
38
+
39
+ # async-easy-model
40
+
41
+ A simplified SQLModel-based ORM for async database operations in Python. async-easy-model provides a clean, intuitive interface for common database operations while leveraging the power of SQLModel and SQLAlchemy.
42
+
43
+ <p align="center">
44
+ <img src="https://img.shields.io/pypi/v/async-easy-model" alt="PyPI Version">
45
+ <img src="https://img.shields.io/pypi/pyversions/async-easy-model" alt="Python Versions">
46
+ <img src="https://img.shields.io/github/license/puntorigen/easy_model" alt="License">
47
+ </p>
48
+
49
+ ## Features
50
+
51
+ - 🚀 Easy-to-use async database operations with standardized methods
52
+ - 🔄 Intuitive APIs with sensible defaults for rapid development
53
+ - 📊 Dictionary-based CRUD operations (select, insert, update, delete)
54
+ - 🔗 Enhanced relationship handling with eager loading and nested operations
55
+ - 🔍 Powerful query methods with flexible ordering support
56
+ - ⚙️ Automatic relationship detection and bidirectional setup
57
+ - 📱 Support for both PostgreSQL and SQLite databases
58
+ - 🛠️ Built on top of SQLModel and SQLAlchemy for robust performance
59
+ - 📝 Type hints for better IDE support
60
+ - 🕒 Automatic `id`, `created_at` and `updated_at` fields provided by default
61
+ - 🔄 Automatic schema migrations for evolving database models
62
+
63
+ ## Installation
64
+
65
+ ```bash
66
+ pip install async-easy-model
67
+ ```
68
+
69
+ ## Basic Usage
70
+
71
+ ```python
72
+ from async_easy_model import EasyModel, init_db, db_config, Field
73
+ from typing import Optional
74
+ from datetime import datetime
75
+
76
+ # Configure your database
77
+ db_config.configure_sqlite("database.db")
78
+
79
+ # Define your model
80
+ class User(EasyModel, table=True):
81
+ #no need to specify id, created_at or updated_at since EasyModel provides them by default
82
+ username: str = Field(unique=True)
83
+ email: str
84
+
85
+ # Initialize your database
86
+ async def setup():
87
+ await init_db()
88
+
89
+ # Use it in your async code
90
+ async def main():
91
+ await setup()
92
+ # Create a new user
93
+ user = await User.insert({
94
+ "username": "john_doe",
95
+ "email": "john@example.com"
96
+ })
97
+
98
+ # Get user ID
99
+ print(f"New user id: {user.id}")
100
+ ```
101
+
102
+ ## CRUD Operations
103
+
104
+ First, let's define some models that we'll use throughout the examples:
105
+
106
+ ```python
107
+ from async_easy_model import EasyModel, Field
108
+ from typing import Optional, List
109
+ from datetime import datetime
110
+
111
+ class User(EasyModel, table=True):
112
+ username: str = Field(unique=True)
113
+ email: str
114
+ is_active: bool = Field(default=True)
115
+
116
+ class Post(EasyModel, table=True):
117
+ title: str
118
+ content: str
119
+ user_id: Optional[int] = Field(default=None, foreign_key="user.id")
120
+
121
+ class Comment(EasyModel, table=True):
122
+ text: str
123
+ post_id: Optional[int] = Field(default=None, foreign_key="post.id")
124
+ user_id: Optional[int] = Field(default=None, foreign_key="user.id")
125
+
126
+ class Department(EasyModel, table=True):
127
+ name: str = Field(unique=True)
128
+
129
+ class Product(EasyModel, table=True):
130
+ name: str
131
+ price: float
132
+ sales: int = Field(default=0)
133
+
134
+ class Book(EasyModel, table=True):
135
+ title: str
136
+ author_id: Optional[int] = Field(default=None, foreign_key="author.id")
137
+
138
+ class Author(EasyModel, table=True):
139
+ name: str
140
+ ```
141
+
142
+ ### Create (Insert)
143
+
144
+ ```python
145
+ # Insert a single record
146
+ user = await User.insert({
147
+ "username": "john_doe",
148
+ "email": "john@example.com"
149
+ })
150
+
151
+ # Insert multiple records
152
+ users = await User.insert([
153
+ {"username": "user1", "email": "user1@example.com"},
154
+ {"username": "user2", "email": "user2@example.com"}
155
+ ])
156
+
157
+ # Insert with nested relationships
158
+ new_post = await Post.insert({
159
+ "title": "My Post",
160
+ "content": "Content here",
161
+ "user": {"username": "jane_doe"}, # Will automatically link to existing user
162
+ "comments": [ # Create multiple comments in a single transaction
163
+ {"text": "Great post!", "user": {"username": "reader1"}},
164
+ {"text": "Thanks for sharing", "user": {"username": "reader2"}}
165
+ ]
166
+ })
167
+ # Access nested data without requerying
168
+ print(f"Post by {new_post.user.username} with {len(new_post.comments)} comments")
169
+
170
+ # Insert with nested one-to-many relationships
171
+ publisher = await Publisher.insert({
172
+ "name": "Example Publisher",
173
+ "books": [ # List of nested objects
174
+ {
175
+ "title": "Python Mastery",
176
+ "genres": [
177
+ {"name": "Programming"},
178
+ {"name": "Education"}
179
+ ]
180
+ },
181
+ {"title": "Data Science Handbook"}
182
+ ]
183
+ })
184
+ # Access nested relationships immediately
185
+ print(f"Publisher: {publisher.name} with {len(publisher.books)} books")
186
+ print(f"First book genres: {[g.name for g in publisher.books[0].genres]}")
187
+ ```
188
+
189
+ ### Read (Retrieve)
190
+
191
+ ```python
192
+ # Select by ID
193
+ user = await User.select({"id": 1})
194
+
195
+ # Select with criteria
196
+ users = await User.select({"is_active": True}, all=True)
197
+
198
+ # Select first matching record
199
+ first_user = await User.select({"is_active": True}, first=True)
200
+
201
+ # Select all records
202
+ all_users = await User.select({}, all=True)
203
+
204
+ # Select with wildcard pattern matching
205
+ gmail_users = await User.select({"email": "*@gmail.com"}, all=True)
206
+
207
+ # Select with ordering
208
+ recent_users = await User.select({}, order_by="-created_at", all=True)
209
+
210
+ # Select with limit
211
+ latest_posts = await Post.select({}, order_by="-created_at", limit=5)
212
+ # Note: limit > 1 automatically sets all=True
213
+
214
+ # Select with multiple ordering fields
215
+ sorted_users = await User.select({}, order_by=["last_name", "first_name"], all=True)
216
+
217
+ # Select with relationship ordering
218
+ posts_by_author = await Post.select({}, order_by="user.username", all=True)
219
+ ```
220
+
221
+ ### Update
222
+
223
+ ```python
224
+ # Update by ID
225
+ user = await User.update({"is_active": False}, 1)
226
+
227
+ # Update by criteria
228
+ count = await User.update(
229
+ {"is_active": False},
230
+ {"last_login": None} # Set all users without login to inactive
231
+ )
232
+
233
+ # Update with relationships
234
+ await User.update(
235
+ {"department": {"name": "Sales"}}, # Update department relationship
236
+ {"username": "john_doe"}
237
+ )
238
+ ```
239
+
240
+ ### Delete
241
+
242
+ ```python
243
+ # Delete by ID
244
+ success = await User.delete(1)
245
+
246
+ # Delete by criteria
247
+ deleted_count = await User.delete({"is_active": False})
248
+
249
+ # Delete with compound criteria
250
+ await Post.delete({"user": {"username": "john_doe"}, "is_published": False})
251
+ ```
252
+
253
+ ## Convenient Query Methods
254
+
255
+ async-easy-model provides simplified methods for common query patterns:
256
+
257
+ ```python
258
+ # Get all records with relationships loaded (default)
259
+ users = await User.all()
260
+
261
+ # Get all records ordered by a field (ascending)
262
+ users = await User.all(order_by="username")
263
+
264
+ # Get all records ordered by a field (descending)
265
+ newest_users = await User.all(order_by="-created_at")
266
+
267
+ # Get all records ordered by multiple fields
268
+ sorted_users = await User.all(order_by=["last_name", "first_name"])
269
+
270
+ # Get all records ordered by relationship fields
271
+ books = await Book.all(order_by="author.name")
272
+
273
+ # Get the first record
274
+ user = await User.first()
275
+
276
+ # Get the most recently created user
277
+ newest_user = await User.first(order_by="-created_at")
278
+
279
+ # Get a limited number of records
280
+ recent_users = await User.limit(10)
281
+
282
+ # Get a limited number of records with ordering
283
+ top_products = await Product.limit(5, order_by="-sales")
284
+ ```
285
+
286
+ ## Enhanced Relationship Handling
287
+
288
+ Using the models defined earlier, here's how to work with relationships:
289
+
290
+ ```python
291
+ # Load all relationships automatically
292
+ post = await Post.select({"id": 1})
293
+ print(post.user.username) # Access related objects directly
294
+
295
+ # Load specific relationships
296
+ post = await Post.get_with_related(1, ["user", "comments"])
297
+
298
+ # Load relationships after fetching
299
+ post = await Post.select({"id": 1}, include_relationships=False)
300
+ await post.load_related(["user", "comments"])
301
+
302
+ # Insert with nested relationships
303
+ new_post = await Post.insert({
304
+ "title": "My Post",
305
+ "content": "Content here",
306
+ "user": {"username": "jane_doe"}, # Will automatically link to existing user
307
+ "comments": [ # Create multiple comments in a single transaction
308
+ {"text": "Great post!", "user": {"username": "reader1"}},
309
+ {"text": "Thanks for sharing", "user": {"username": "reader2"}}
310
+ ]
311
+ })
312
+ # Access nested data without requerying
313
+ print(f"Post by {new_post.user.username} with {len(new_post.comments)} comments")
314
+
315
+ # Convert to dictionary with nested relationships
316
+ post_dict = post.to_dict(include_relationships=True, max_depth=2)
317
+ ```
318
+
319
+ ## Automatic Relationship Detection
320
+
321
+ The package can automatically detect and set up bidirectional relationships between models:
322
+
323
+ ```python
324
+ class User(EasyModel, table=True):
325
+ username: str
326
+
327
+ class Post(EasyModel, table=True):
328
+ title: str
329
+ user_id: int = Field(foreign_key="user.id")
330
+
331
+ # After init_db():
332
+ # - post.user relationship is automatically available
333
+ # - user.posts relationship is automatically available
334
+ ```
335
+
336
+ ## Database Configuration
337
+
338
+ ```python
339
+ # SQLite Configuration
340
+ db_config.configure_sqlite("database.db")
341
+ db_config.configure_sqlite(":memory:") # In-memory database
342
+
343
+ # PostgreSQL Configuration
344
+ db_config.configure_postgres(
345
+ user="your_user",
346
+ password="your_password",
347
+ host="localhost",
348
+ port="5432",
349
+ database="your_database"
350
+ )
351
+
352
+ # Custom Connection URL
353
+ db_config.set_connection_url("postgresql+asyncpg://user:password@localhost:5432/database")
354
+ ```
355
+
356
+ ## Documentation
357
+
358
+ For more detailed documentation, please visit the [GitHub repository](https://github.com/puntorigen/easy-model) or refer to the [DOCS.md](https://github.com/puntorigen/easy-model/blob/master/DOCS.md) file.
359
+
360
+ ## License
361
+
362
+ This project is licensed under the MIT License - see the LICENSE file for details.
@@ -0,0 +1,10 @@
1
+ async_easy_model/__init__.py,sha256=u1yerriPm93E_uXcK9r-y7wcMOV1F4t-KjKEooCaiIA,1642
2
+ async_easy_model/auto_relationships.py,sha256=VetxcrOdKGGSImTiysRFR8PSOSlo50RqnVG95CLe8Jg,22433
3
+ async_easy_model/migrations.py,sha256=rYDGCGlruSugAmPfdIF2-uhyG6UvC_2qbF3BXJ084qI,17803
4
+ async_easy_model/model.py,sha256=G-0htjy78Nae5HdNE2dMGV8Z8s-rUNRTUANcKArxrDM,57852
5
+ async_easy_model/relationships.py,sha256=vR5BsJpGaDcecCcNlg9-ouZfxFXFQv5kOyiXhKp_T7A,3286
6
+ async_easy_model-0.2.3.dist-info/LICENSE,sha256=uwDkl6oHbRltW7vYKNc4doJyhtwhyrSNFFlPpKATwLE,1072
7
+ async_easy_model-0.2.3.dist-info/METADATA,sha256=SZJy5Ozs7Jn-J3qVTiVB637IGza-5C6hdet_dPocM3I,10720
8
+ async_easy_model-0.2.3.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
9
+ async_easy_model-0.2.3.dist-info/top_level.txt,sha256=e5_47sGmJnyxz2msfwU6C316EqmrSd9RGIYwZyWx68E,17
10
+ async_easy_model-0.2.3.dist-info/RECORD,,
@@ -1,343 +0,0 @@
1
- Metadata-Version: 2.2
2
- Name: async-easy-model
3
- Version: 0.2.1
4
- Summary: A simplified SQLModel-based ORM for async database operations
5
- Home-page: https://github.com/puntorigen/easy-model
6
- Author: Pablo Schaffner
7
- Author-email: pablo@puntorigen.com
8
- Keywords: orm,sqlmodel,database,async,postgresql,sqlite
9
- Classifier: Development Status :: 3 - Alpha
10
- Classifier: Intended Audience :: Developers
11
- Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Operating System :: OS Independent
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.7
15
- Classifier: Programming Language :: Python :: 3.8
16
- Classifier: Programming Language :: Python :: 3.9
17
- Classifier: Programming Language :: Python :: 3.10
18
- Classifier: Programming Language :: Python :: 3.11
19
- Requires-Python: >=3.7
20
- Description-Content-Type: text/markdown
21
- License-File: LICENSE
22
- Requires-Dist: sqlmodel>=0.0.8
23
- Requires-Dist: sqlalchemy>=2.0.0
24
- Requires-Dist: asyncpg>=0.25.0
25
- Requires-Dist: aiosqlite>=0.19.0
26
- Requires-Dist: greenlet>=3.1.1
27
- Requires-Dist: inflection>=0.5.1
28
- Dynamic: author
29
- Dynamic: author-email
30
- Dynamic: classifier
31
- Dynamic: description
32
- Dynamic: description-content-type
33
- Dynamic: home-page
34
- Dynamic: keywords
35
- Dynamic: requires-dist
36
- Dynamic: requires-python
37
- Dynamic: summary
38
-
39
- # EasyModel
40
-
41
- A simplified SQLModel-based ORM for async database operations in Python. EasyModel provides a clean and intuitive interface for common database operations while leveraging the power of SQLModel and SQLAlchemy.
42
-
43
- ## Features
44
-
45
- - Easy-to-use async database operations with standardized methods
46
- - Intuitive APIs with sensible defaults (relationships loaded by default)
47
- - Dictionary-based CRUD operations (select, insert, update, delete)
48
- - Built on top of SQLModel and SQLAlchemy
49
- - Support for both PostgreSQL and SQLite databases
50
- - Type hints for better IDE support
51
- - Automatic `id`, `created_at` and `updated_at` fields provided by default
52
- - Enhanced relationship handling with eager loading and nested operations
53
- - Flexible ordering of query results with support for relationship fields
54
- - Automatic relationship detection
55
- - Automatic schema migrations for evolving database models
56
-
57
- ## Installation
58
-
59
- ```bash
60
- pip install async-easy-model
61
- ```
62
-
63
- ## Quick Start with Standardized API
64
-
65
- This section demonstrates the preferred usage of EasyModel with its standardized API methods.
66
-
67
- ```python
68
- from async_easy_model import EasyModel, init_db, db_config, Field
69
- from typing import Optional
70
- from datetime import datetime
71
-
72
- # Configure your database
73
- db_config.configure_sqlite("database.db")
74
-
75
- # Define your model
76
- class User(EasyModel, table=True):
77
- # id field is automatically created (primary key)
78
- username: str = Field(unique=True)
79
- email: str
80
- is_active: bool = Field(default=True)
81
- # created_at and updated_at fields are automatically included
82
-
83
- class Post(EasyModel, table=True):
84
- # id field is automatically created (primary key)
85
- title: str
86
- content: str
87
- user_id: Optional[int] = Field(default=None, foreign_key="user.id")
88
- # created_at and updated_at fields are automatically included
89
-
90
- # Initialize your database
91
- async def setup():
92
- await init_db()
93
-
94
- # Use the standardized methods in your code
95
- async def main():
96
- # Insert a new user
97
- user = await User.insert({
98
- "username": "john_doe",
99
- "email": "john@example.com"
100
- })
101
-
102
- # Insert with relationships
103
- post = await Post.insert({
104
- "title": "My First Post",
105
- "content": "Hello world!",
106
- "user": {"username": "john_doe"} # Will find the user by username
107
- })
108
-
109
- # Select with criteria
110
- active_users = await User.select({"is_active": True}, all=True)
111
-
112
- # Select with wildcard search
113
- gmail_users = await User.select({"email": "*@gmail.com"}, all=True)
114
-
115
- # Select with ordering and limit
116
- recent_posts = await Post.select({}, order_by="-id", limit=5)
117
- # Note: limit > 1 automatically sets all=True
118
-
119
- # Update by criteria
120
- await User.update(
121
- {"is_active": False},
122
- {"last_login": None} # Update users with no login
123
- )
124
-
125
- # Delete with criteria
126
- await Post.delete({"user": {"username": "john_doe"}})
127
- ```
128
-
129
- ## Standardized API Methods
130
-
131
- EasyModel provides a set of standardized methods that make it easy and intuitive to perform common database operations.
132
-
133
- ### Select Method
134
-
135
- The `select()` method is a powerful and flexible way to query data:
136
-
137
- ```python
138
- # Get active users
139
- active_users = await User.select({"is_active": True}, all=True)
140
-
141
- # Get single user by username (returns first match)
142
- user = await User.select({"username": "john_doe"})
143
-
144
- # Explicitly get first result
145
- first_admin = await User.select({"role": "admin"}, first=True)
146
-
147
- # With wildcard pattern matching
148
- gmail_users = await User.select({"email": "*@gmail.com"}, all=True)
149
-
150
- # With ordering and limit (automatically sets all=True)
151
- newest_users = await User.select({}, order_by="-created_at", limit=5)
152
-
153
- # With ordering by multiple fields
154
- sorted_users = await User.select({}, order_by=["last_name", "first_name"], all=True)
155
-
156
- # With ordering by nested relationship fields using dot notation
157
- books_by_author = await Book.select({}, order_by="author.name", all=True)
158
- posts_by_popularity = await Post.select({}, order_by=["-comments.count", "title"], all=True)
159
- ```
160
-
161
- ### Insert Method
162
-
163
- The `insert()` method supports both single and multiple records with relationship handling and returns the newly created records with assigned IDs and auto-generated fields (`created_at`, `updated_at`):
164
-
165
- ```python
166
- # Insert single record
167
- user = await User.insert({
168
- "username": "john_doe",
169
- "email": "john@example.com"
170
- })
171
- print(user.id) # Newly assigned ID is available
172
- print(user.created_at) # Auto-generated timestamp is available
173
-
174
- # Insert with relationship
175
- comment = await Comment.insert({
176
- "text": "Great post!",
177
- "post": {"id": 1}, # Link by ID
178
- "author": {"username": "jane_doe"} # Link by attribute lookup
179
- })
180
-
181
- # Insert multiple records
182
- products = await Product.insert([
183
- {"name": "Product 1", "price": 10.99},
184
- {"name": "Product 2", "price": 24.99}
185
- ])
186
- ```
187
-
188
- ### Update Method
189
-
190
- The `update()` method allows updates based on ID or criteria:
191
-
192
- ```python
193
- # Update by ID
194
- user = await User.update({"email": "new@example.com"}, 1)
195
-
196
- # Update by criteria
197
- count = await User.update(
198
- {"is_active": False},
199
- {"last_login": None} # Set all users without login to inactive
200
- )
201
-
202
- # Update with relationships
203
- await User.update(
204
- {"department": {"name": "Sales"}}, # Update department relationship
205
- {"username": "john_doe"}
206
- )
207
- ```
208
-
209
- ### Delete Method
210
-
211
- The `delete()` method provides a consistent way to delete records:
212
-
213
- ```python
214
- # Delete by ID
215
- success = await User.delete(1)
216
-
217
- # Delete by criteria
218
- deleted_count = await User.delete({"is_active": False})
219
-
220
- # Delete with compound criteria
221
- await Post.delete({"author": {"username": "john_doe"}, "is_published": False})
222
- ```
223
-
224
- ## Convenience Query Methods
225
-
226
- EasyModel also provides convenient shorthand methods for common queries:
227
-
228
- ```python
229
- # Get all records with relationships loaded (default)
230
- users = await User.all()
231
-
232
- # Get all records ordered by a field
233
- users = await User.all(order_by="username")
234
-
235
- # Get the first record
236
- user = await User.first()
237
-
238
- # Get the most recently created user
239
- newest_user = await User.first(order_by="-created_at")
240
-
241
- # Get limited records
242
- recent_users = await User.limit(10, order_by="-created_at")
243
- ```
244
-
245
- ## Automatic Relationship Detection
246
-
247
- EasyModel supports automatic relationship detection based on foreign key fields:
248
-
249
- ```python
250
- from async_easy_model import enable_auto_relationships, EasyModel, init_db, Field
251
- from typing import Optional
252
-
253
- # Enable automatic relationship detection
254
- enable_auto_relationships()
255
-
256
- # Define models with foreign keys but without explicit relationships
257
- class Author(EasyModel, table=True):
258
- # id field is automatically created (primary key)
259
- name: str
260
-
261
- class Book(EasyModel, table=True):
262
- title: str
263
- author_id: Optional[int] = Field(default=None, foreign_key="author.id")
264
- # No need to define Relationship attributes - they're detected automatically!
265
-
266
- # Use the automatically detected relationships
267
- async def main():
268
- await init_db()
269
- author = await Author.insert({"name": "Jane Author"})
270
-
271
- book = await Book.insert({
272
- "title": "Auto-detected Relationships",
273
- "author_id": author.id
274
- })
275
-
276
- # Show the book with its author
277
- print(f"Book: {book.title}, Author: {book.author.name}")
278
- ```
279
-
280
- ### Another Example
281
- ```python
282
- # Using the standard insert with nested dictionaries (recommended)
283
- new_book = await Book.insert({
284
- "title": "New Book",
285
- "author": {"name": "Jane Author"} # Will create or find the author
286
- })
287
- ```
288
-
289
- ## Automatic Schema Migrations
290
-
291
- EasyModel includes automatic database migration capabilities, similar to alembic:
292
-
293
- ```python
294
- from async_easy_model import MigrationManager
295
-
296
- async def apply_migrations():
297
- migration_manager = MigrationManager()
298
- results = await migration_manager.migrate_models([User, Post])
299
-
300
- if results:
301
- print("Migrations applied:")
302
- for model_name, changes in results.items():
303
- print(f" {model_name}:")
304
- for change in changes:
305
- print(f" - {change}")
306
- ```
307
-
308
- ## Legacy API Methods
309
-
310
- The following methods are still supported but the standardized methods above are recommended for new code:
311
-
312
- ### Traditional CRUD Operations
313
-
314
- ```python
315
- # Create a record (consider using insert() instead)
316
- user = User(username="john_doe", email="john@example.com")
317
- await user.save()
318
-
319
- # Get by ID (consider using select() instead)
320
- user = await User.get_by_id(1)
321
-
322
- # Get by attribute (consider using select() instead)
323
- users = await User.get_by_attribute(is_active=True, all=True)
324
-
325
- # Update by ID (consider using update() instead)
326
- updated_user = await User.update_by_id(1, {"email": "new_email@example.com"})
327
-
328
- # Update by attribute (consider using update() instead)
329
- await User.update_by_attribute(
330
- {"is_active": False}, # Update data
331
- is_active=True, role="guest" # Filter criteria
332
- )
333
-
334
- # Delete by ID (consider using delete() instead)
335
- success = await User.delete_by_id(1)
336
-
337
- # Delete by attribute (consider using delete() instead)
338
- deleted_count = await User.delete_by_attribute(is_active=False)
339
- ```
340
-
341
- ## Complete Documentation
342
-
343
- For complete documentation, including advanced features, please see the [full documentation](DOCS.md).
@@ -1,10 +0,0 @@
1
- async_easy_model/__init__.py,sha256=JcNQU_W8DUFnGVoOkbwfLDz5akT6jjMNVYLzWy_HA6w,1643
2
- async_easy_model/auto_relationships.py,sha256=VetxcrOdKGGSImTiysRFR8PSOSlo50RqnVG95CLe8Jg,22433
3
- async_easy_model/migrations.py,sha256=rYDGCGlruSugAmPfdIF2-uhyG6UvC_2qbF3BXJ084qI,17803
4
- async_easy_model/model.py,sha256=D8nXgiYGu1WdNGciVSESyH4hmYmEx2_Rs6pBQQpyEA8,52958
5
- async_easy_model/relationships.py,sha256=vR5BsJpGaDcecCcNlg9-ouZfxFXFQv5kOyiXhKp_T7A,3286
6
- async_easy_model-0.2.1.dist-info/LICENSE,sha256=uwDkl6oHbRltW7vYKNc4doJyhtwhyrSNFFlPpKATwLE,1072
7
- async_easy_model-0.2.1.dist-info/METADATA,sha256=RrlZpVzUvAO8RWPMH7g9w2DUKSyjsXqxfKsPzaOs6QM,10420
8
- async_easy_model-0.2.1.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
9
- async_easy_model-0.2.1.dist-info/top_level.txt,sha256=e5_47sGmJnyxz2msfwU6C316EqmrSd9RGIYwZyWx68E,17
10
- async_easy_model-0.2.1.dist-info/RECORD,,