debugorm 0.1.0__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.
debugorm/__init__.py ADDED
@@ -0,0 +1,105 @@
1
+ """
2
+ DebugORM — a minimal, plugin-driven ORM for understanding and debugging SQL.
3
+
4
+ Quick start::
5
+
6
+ from debugorm import configure, Model
7
+ from debugorm.fields import IntegerField, StringField
8
+
9
+ configure("myapp.db") # or ":memory:"
10
+
11
+ class User(Model):
12
+ id = IntegerField(primary_key=True)
13
+ name = StringField()
14
+ age = IntegerField()
15
+
16
+ User.create_table()
17
+
18
+ alice = User(name="Alice", age=25)
19
+ alice.save()
20
+
21
+ users = User.objects.filter(age__gt=18).order_by("-age").all()
22
+
23
+ # Debug any query on the fly
24
+ User.objects.debug(explain=True, pretty=True).filter(age__gt=18).all()
25
+
26
+ # Compare two queries
27
+ q1 = User.objects.filter(age__gt=18)
28
+ q2 = User.objects.filter(age__gt=21)
29
+ print(q1.diff(q2))
30
+ """
31
+
32
+ from .core.model import Model
33
+ from .core.query import Query
34
+ from .core.pipeline import QueryPipeline, QueryResult, PipelineContext
35
+ from .core.compiler import SQLCompiler, CompiledQuery
36
+ from .core.manager import Manager, QuerySet
37
+ from .db.connection import configure, get_connection, set_connection, Connection
38
+ from .fields import Field, IntegerField, StringField
39
+ from .plugins import (
40
+ Plugin,
41
+ ExplainPlugin,
42
+ DryRunPlugin,
43
+ LoggingPlugin,
44
+ VisualizePlugin,
45
+ PrettyPrintPlugin,
46
+ )
47
+
48
+ __version__ = "0.1.0"
49
+ __all__ = [
50
+ "Model",
51
+ "Query",
52
+ "QueryPipeline",
53
+ "QueryResult",
54
+ "PipelineContext",
55
+ "SQLCompiler",
56
+ "CompiledQuery",
57
+ "Manager",
58
+ "QuerySet",
59
+ "configure",
60
+ "get_connection",
61
+ "set_connection",
62
+ "Connection",
63
+ "Field",
64
+ "IntegerField",
65
+ "StringField",
66
+ "Plugin",
67
+ "ExplainPlugin",
68
+ "DryRunPlugin",
69
+ "LoggingPlugin",
70
+ "VisualizePlugin",
71
+ "PrettyPrintPlugin",
72
+ "DebugORM",
73
+ ]
74
+
75
+
76
+ class DebugORM:
77
+ """
78
+ Optional facade for configuring a fixed plugin set across all models::
79
+
80
+ orm = DebugORM(":memory:", plugins=[ExplainPlugin(), LoggingPlugin()])
81
+ orm.install() # applies the pipeline to every existing Model subclass
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ db_path: str = ":memory:",
87
+ plugins: list | None = None,
88
+ ) -> None:
89
+ self.connection = configure(db_path)
90
+ self.plugins: list = plugins or []
91
+ self._pipeline = QueryPipeline(plugins=self.plugins, connection=self.connection)
92
+
93
+ def install(self) -> None:
94
+ """Push the configured pipeline onto every ``Model`` subclass defined so far."""
95
+ for subclass in self._all_subclasses(Model):
96
+ if hasattr(subclass, "objects"):
97
+ subclass.objects.pipeline = self._pipeline
98
+
99
+ @staticmethod
100
+ def _all_subclasses(base: type) -> list:
101
+ result = []
102
+ for cls in base.__subclasses__():
103
+ result.append(cls)
104
+ result.extend(DebugORM._all_subclasses(cls))
105
+ return result
@@ -0,0 +1,17 @@
1
+ from .model import Model
2
+ from .query import Query
3
+ from .compiler import SQLCompiler, CompiledQuery
4
+ from .pipeline import QueryPipeline, QueryResult, PipelineContext
5
+ from .manager import Manager, QuerySet
6
+
7
+ __all__ = [
8
+ "Model",
9
+ "Query",
10
+ "SQLCompiler",
11
+ "CompiledQuery",
12
+ "QueryPipeline",
13
+ "QueryResult",
14
+ "PipelineContext",
15
+ "Manager",
16
+ "QuerySet",
17
+ ]
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Any, List, Tuple
4
+
5
+ from .query import Query
6
+
7
+
8
+ @dataclass
9
+ class CompiledQuery:
10
+ """The output of SQL compilation: a statement string and its parameter tuple."""
11
+
12
+ sql: str
13
+ params: Tuple[Any, ...]
14
+ query: Query
15
+
16
+ def __str__(self) -> str:
17
+ return self.sql
18
+
19
+ def interpolated(self) -> str:
20
+ """SQL with parameters substituted inline — for display only."""
21
+ result = self.sql
22
+ for p in self.params:
23
+ placeholder = f"'{p}'" if isinstance(p, str) else str(p)
24
+ result = result.replace("?", placeholder, 1)
25
+ return result
26
+
27
+
28
+ class SQLCompiler:
29
+ """Translates a ``Query`` object into a ``CompiledQuery``."""
30
+
31
+ def compile(self, query: Query) -> CompiledQuery:
32
+ handlers = {
33
+ "SELECT": self._compile_select,
34
+ "INSERT": self._compile_insert,
35
+ "UPDATE": self._compile_update,
36
+ "DELETE": self._compile_delete,
37
+ }
38
+ handler = handlers.get(query.query_type)
39
+ if handler is None:
40
+ raise ValueError(f"Unknown query type: {query.query_type!r}")
41
+ return handler(query)
42
+
43
+ def _compile_select(self, query: Query) -> CompiledQuery:
44
+ fields = "*" if not query.select_fields else ", ".join(query.select_fields)
45
+ sql = f"SELECT {fields} FROM {query.table}"
46
+ params: List[Any] = []
47
+
48
+ where, where_params = self._compile_where(query)
49
+ if where:
50
+ sql += f" WHERE {where}"
51
+ params.extend(where_params)
52
+
53
+ if query.order_by_clauses:
54
+ order = ", ".join(
55
+ f"{c.field} {'DESC' if c.descending else 'ASC'}"
56
+ for c in query.order_by_clauses
57
+ )
58
+ sql += f" ORDER BY {order}"
59
+
60
+ if query.limit_value is not None:
61
+ sql += " LIMIT ?"
62
+ params.append(query.limit_value)
63
+ elif query.offset_value is not None:
64
+ # SQLite requires LIMIT before OFFSET; -1 means no upper bound
65
+ sql += " LIMIT -1"
66
+
67
+ if query.offset_value is not None:
68
+ sql += " OFFSET ?"
69
+ params.append(query.offset_value)
70
+
71
+ return CompiledQuery(sql=sql, params=tuple(params), query=query)
72
+
73
+ def _compile_insert(self, query: Query) -> CompiledQuery:
74
+ columns = list(query.data.keys())
75
+ values = list(query.data.values())
76
+ placeholders = ", ".join(["?"] * len(columns))
77
+ sql = (
78
+ f"INSERT INTO {query.table} "
79
+ f"({', '.join(columns)}) VALUES ({placeholders})"
80
+ )
81
+ return CompiledQuery(sql=sql, params=tuple(values), query=query)
82
+
83
+ def _compile_update(self, query: Query) -> CompiledQuery:
84
+ pk_name = self._pk_field_name(query)
85
+ set_parts: List[str] = []
86
+ params: List[Any] = []
87
+
88
+ for col, val in query.data.items():
89
+ if col != pk_name:
90
+ set_parts.append(f"{col} = ?")
91
+ params.append(val)
92
+
93
+ sql = f"UPDATE {query.table} SET {', '.join(set_parts)}"
94
+
95
+ where, where_params = self._compile_where(query)
96
+ if where:
97
+ sql += f" WHERE {where}"
98
+ params.extend(where_params)
99
+ elif query.pk_value is not None:
100
+ sql += f" WHERE {pk_name} = ?"
101
+ params.append(query.pk_value)
102
+
103
+ return CompiledQuery(sql=sql, params=tuple(params), query=query)
104
+
105
+ def _compile_delete(self, query: Query) -> CompiledQuery:
106
+ pk_name = self._pk_field_name(query)
107
+ sql = f"DELETE FROM {query.table}"
108
+ params: List[Any] = []
109
+
110
+ where, where_params = self._compile_where(query)
111
+ if where:
112
+ sql += f" WHERE {where}"
113
+ params.extend(where_params)
114
+ elif query.pk_value is not None:
115
+ sql += f" WHERE {pk_name} = ?"
116
+ params.append(query.pk_value)
117
+
118
+ return CompiledQuery(sql=sql, params=tuple(params), query=query)
119
+
120
+ def _compile_where(self, query: Query) -> Tuple[str, List[Any]]:
121
+ if not query.conditions:
122
+ return "", []
123
+
124
+ parts: List[str] = []
125
+ params: List[Any] = []
126
+
127
+ for cond in query.conditions:
128
+ if cond.operator == "IN":
129
+ placeholders = ", ".join(["?"] * len(cond.value))
130
+ parts.append(f"{cond.field} IN ({placeholders})")
131
+ params.extend(cond.value)
132
+ elif cond.operator in ("IS NULL", "IS NOT NULL"):
133
+ parts.append(f"{cond.field} {cond.operator}")
134
+ else:
135
+ parts.append(f"{cond.field} {cond.operator} ?")
136
+ params.append(cond.value)
137
+
138
+ return " AND ".join(parts), params
139
+
140
+ def _pk_field_name(self, query: Query) -> str:
141
+ for name, f in query.model._fields.items():
142
+ if f.primary_key:
143
+ return name
144
+ return "id"
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Iterator, List, Optional, TYPE_CHECKING
3
+
4
+ from .query import Query, OrderByClause
5
+ from .pipeline import QueryPipeline
6
+
7
+ if TYPE_CHECKING:
8
+ from .model import Model
9
+ from ..plugins.base import Plugin
10
+
11
+
12
+ class DoesNotExist(Exception):
13
+ pass
14
+
15
+
16
+ class MultipleObjectsReturned(Exception):
17
+ pass
18
+
19
+
20
+ class QuerySet:
21
+ """
22
+ Lazy, chainable query builder.
23
+
24
+ Every filtering or ordering call returns a *new* ``QuerySet`` — the
25
+ original is never mutated. The database is only hit when results are
26
+ materialised via ``all()``, ``get()``, ``first()``, ``count()``, or
27
+ direct iteration.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ model: type,
33
+ pipeline: QueryPipeline,
34
+ query: Optional[Query] = None,
35
+ ) -> None:
36
+ self._model = model
37
+ self._pipeline = pipeline
38
+ self._query = query if query is not None else Query(model)
39
+
40
+ def filter(self, **kwargs: Any) -> "QuerySet":
41
+ """Append WHERE conditions. Multiple calls are AND-ed together."""
42
+ new_query = self._query.copy()
43
+ for lookup, value in kwargs.items():
44
+ new_query.add_filter(lookup, value)
45
+ return QuerySet(self._model, self._pipeline, new_query)
46
+
47
+ def order_by(self, *fields: str) -> "QuerySet":
48
+ """
49
+ Order results. Prefix a field name with ``-`` for descending order::
50
+
51
+ User.objects.order_by('-age', 'name')
52
+ """
53
+ new_query = self._query.copy()
54
+ for f in fields:
55
+ descending = f.startswith("-")
56
+ new_query.order_by_clauses.append(OrderByClause(f.lstrip("-"), descending))
57
+ return QuerySet(self._model, self._pipeline, new_query)
58
+
59
+ def limit(self, n: int) -> "QuerySet":
60
+ new_query = self._query.copy()
61
+ new_query.limit_value = n
62
+ return QuerySet(self._model, self._pipeline, new_query)
63
+
64
+ def offset(self, n: int) -> "QuerySet":
65
+ new_query = self._query.copy()
66
+ new_query.offset_value = n
67
+ return QuerySet(self._model, self._pipeline, new_query)
68
+
69
+ def all(self) -> List["Model"]:
70
+ """Execute the query and return a list of model instances."""
71
+ result = self._pipeline.run(self._query)
72
+ return [self._model._from_row(row) for row in result.rows]
73
+
74
+ def get(self, **kwargs: Any) -> "Model":
75
+ """Return exactly one object; raise if zero or multiple match."""
76
+ qs = self.filter(**kwargs) if kwargs else self
77
+ results = qs.all()
78
+ if not results:
79
+ raise self._model.DoesNotExist(
80
+ f"{self._model.__name__} matching query does not exist"
81
+ )
82
+ if len(results) > 1:
83
+ raise self._model.MultipleObjectsReturned(
84
+ f"get() returned more than one {self._model.__name__}"
85
+ )
86
+ return results[0]
87
+
88
+ def first(self) -> Optional["Model"]:
89
+ results = self.limit(1).all()
90
+ return results[0] if results else None
91
+
92
+ def count(self) -> int:
93
+ count_query = self._query.copy()
94
+ count_query.select_fields = ["COUNT(*) as count"]
95
+ count_query.limit_value = None
96
+ count_query.offset_value = None
97
+ count_query.order_by_clauses = []
98
+ result = self._pipeline.run(count_query)
99
+ return result.rows[0]["count"] if result.rows else 0
100
+
101
+ def exists(self) -> bool:
102
+ return self.count() > 0
103
+
104
+ def debug(
105
+ self,
106
+ explain: bool = False,
107
+ dry_run: bool = False,
108
+ log: bool = False,
109
+ visualize: bool = False,
110
+ pretty: bool = False,
111
+ ) -> "QuerySet":
112
+ """
113
+ Attach debug plugins on the fly::
114
+
115
+ User.objects.debug(explain=True, pretty=True).filter(age__gt=18).all()
116
+ """
117
+ extra: List["Plugin"] = []
118
+
119
+ if visualize:
120
+ from ..plugins.visualize import VisualizePlugin
121
+ extra.append(VisualizePlugin())
122
+ if log:
123
+ from ..plugins.logging_plugin import LoggingPlugin
124
+ extra.append(LoggingPlugin())
125
+ if pretty:
126
+ from ..plugins.pretty_print import PrettyPrintPlugin
127
+ extra.append(PrettyPrintPlugin())
128
+ if explain:
129
+ from ..plugins.explain import ExplainPlugin
130
+ extra.append(ExplainPlugin())
131
+ if dry_run:
132
+ from ..plugins.dry_run import DryRunPlugin
133
+ extra.append(DryRunPlugin())
134
+
135
+ return QuerySet(self._model, self._pipeline.with_plugins(extra), self._query.copy())
136
+
137
+ def diff(self, other: "QuerySet") -> str:
138
+ """
139
+ Compare two ``QuerySet`` objects at the internal representation level::
140
+
141
+ q1 = User.objects.filter(age__gt=18)
142
+ q2 = User.objects.filter(age__gt=21)
143
+ print(q1.diff(q2))
144
+ # - age > 18
145
+ # + age > 21
146
+ """
147
+ return self._query.diff(other._query)
148
+
149
+ def __iter__(self) -> Iterator["Model"]:
150
+ return iter(self.all())
151
+
152
+ def __len__(self) -> int:
153
+ return self.count()
154
+
155
+ def __repr__(self) -> str:
156
+ return f"QuerySet<{self._model.__name__}>"
157
+
158
+
159
+ class Manager:
160
+ """
161
+ Entry point for all database queries on a model.
162
+
163
+ Attached to every ``Model`` subclass as ``Model.objects`` by ``ModelMeta``.
164
+ """
165
+
166
+ def __init__(self, model_class: type) -> None:
167
+ self._model = model_class
168
+ self._pipeline: Optional[QueryPipeline] = None
169
+
170
+ @property
171
+ def pipeline(self) -> QueryPipeline:
172
+ if self._pipeline is None:
173
+ self._pipeline = QueryPipeline()
174
+ return self._pipeline
175
+
176
+ @pipeline.setter
177
+ def pipeline(self, value: QueryPipeline) -> None:
178
+ self._pipeline = value
179
+
180
+ def get_queryset(self) -> QuerySet:
181
+ return QuerySet(self._model, self.pipeline)
182
+
183
+ def filter(self, **kwargs: Any) -> QuerySet:
184
+ return self.get_queryset().filter(**kwargs)
185
+
186
+ def all(self) -> List["Model"]:
187
+ return self.get_queryset().all()
188
+
189
+ def get(self, **kwargs: Any) -> "Model":
190
+ return self.get_queryset().get(**kwargs)
191
+
192
+ def order_by(self, *fields: str) -> QuerySet:
193
+ return self.get_queryset().order_by(*fields)
194
+
195
+ def first(self) -> Optional["Model"]:
196
+ return self.get_queryset().first()
197
+
198
+ def count(self) -> int:
199
+ return self.get_queryset().count()
200
+
201
+ def create(self, **kwargs: Any) -> "Model":
202
+ instance = self._model(**kwargs)
203
+ instance.save()
204
+ return instance
205
+
206
+ def debug(self, **kwargs: Any) -> QuerySet:
207
+ return self.get_queryset().debug(**kwargs)
debugorm/core/model.py ADDED
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING
3
+
4
+ from ..fields.base import Field
5
+ from ..db.connection import get_connection
6
+
7
+ if TYPE_CHECKING:
8
+ pass
9
+
10
+
11
+ class ModelMeta(type):
12
+ """
13
+ Metaclass that wires up fields, the table name, and the ``Manager``
14
+ when a ``Model`` subclass is defined.
15
+ """
16
+
17
+ def __new__(
18
+ mcs,
19
+ name: str,
20
+ bases: Tuple[type, ...],
21
+ namespace: Dict[str, Any],
22
+ ) -> "ModelMeta":
23
+ fields: Dict[str, Field] = {}
24
+
25
+ for base in bases:
26
+ if hasattr(base, "_fields"):
27
+ fields.update(base._fields)
28
+
29
+ for attr_name, attr_val in list(namespace.items()):
30
+ if isinstance(attr_val, Field):
31
+ attr_val.name = attr_name
32
+ fields[attr_name] = attr_val
33
+
34
+ namespace["_fields"] = fields
35
+
36
+ inner_meta = namespace.get("Meta")
37
+ if inner_meta is not None and hasattr(inner_meta, "table_name"):
38
+ namespace["_table_name"] = inner_meta.table_name
39
+ else:
40
+ namespace["_table_name"] = name.lower() + "s"
41
+
42
+ cls = super().__new__(mcs, name, bases, namespace)
43
+
44
+ if name != "Model":
45
+ from .manager import Manager, DoesNotExist, MultipleObjectsReturned
46
+
47
+ cls.objects = Manager(cls)
48
+ cls.DoesNotExist = type("DoesNotExist", (DoesNotExist,), {})
49
+ cls.MultipleObjectsReturned = type(
50
+ "MultipleObjectsReturned", (MultipleObjectsReturned,), {}
51
+ )
52
+
53
+ return cls
54
+
55
+
56
+ class Model(metaclass=ModelMeta):
57
+ """
58
+ Base class for all DebugORM models::
59
+
60
+ class User(Model):
61
+ id = IntegerField(primary_key=True)
62
+ name = StringField()
63
+ age = IntegerField()
64
+ """
65
+
66
+ _fields: Dict[str, Field] = {}
67
+ _table_name: str = ""
68
+
69
+ def __init__(self, **kwargs: Any) -> None:
70
+ for field_name, field_obj in self._fields.items():
71
+ value = kwargs.get(field_name, field_obj.default)
72
+ object.__setattr__(self, field_name, value)
73
+ for key, value in kwargs.items():
74
+ if key not in self._fields:
75
+ object.__setattr__(self, key, value)
76
+
77
+ @classmethod
78
+ def _from_row(cls, row: Dict[str, Any]) -> "Model":
79
+ """Reconstruct a model instance from a raw DB row dict."""
80
+ instance = cls.__new__(cls)
81
+ for field_name, field_obj in cls._fields.items():
82
+ object.__setattr__(instance, field_name, field_obj.from_db(row.get(field_name)))
83
+ return instance
84
+
85
+ def _pk_info(self) -> Tuple[Optional[str], Optional[Field]]:
86
+ for name, f in self._fields.items():
87
+ if f.primary_key:
88
+ return name, f
89
+ return None, None
90
+
91
+ def _pk_value(self) -> Any:
92
+ pk_name, _ = self._pk_info()
93
+ return getattr(self, pk_name, None) if pk_name else None
94
+
95
+ def save(self) -> None:
96
+ """INSERT if this is a new instance, UPDATE if it already has a PK."""
97
+ from .query import Query
98
+ from .pipeline import QueryPipeline
99
+
100
+ pk_name, _ = self._pk_info()
101
+ pk_val = self._pk_value()
102
+
103
+ data: Dict[str, Any] = {}
104
+ for field_name, field_obj in self._fields.items():
105
+ value = getattr(self, field_name, None)
106
+ field_obj.validate(value)
107
+ if field_obj.primary_key and pk_val is None:
108
+ continue
109
+ data[field_name] = field_obj.to_db(value)
110
+
111
+ pipeline = QueryPipeline(connection=get_connection())
112
+
113
+ if pk_val is None:
114
+ query = Query(self.__class__)
115
+ query.query_type = "INSERT"
116
+ query.data = data
117
+ result = pipeline.run(query)
118
+ if pk_name and result.last_insert_id is not None:
119
+ object.__setattr__(self, pk_name, result.last_insert_id)
120
+ else:
121
+ query = Query(self.__class__)
122
+ query.query_type = "UPDATE"
123
+ query.data = data
124
+ query.pk_value = pk_val
125
+ pipeline.run(query)
126
+
127
+ def delete(self) -> None:
128
+ """Delete this instance from the database and clear its primary key."""
129
+ from .query import Query
130
+ from .pipeline import QueryPipeline
131
+
132
+ pk_name, _ = self._pk_info()
133
+ pk_val = self._pk_value()
134
+
135
+ if pk_val is None:
136
+ raise ValueError(
137
+ f"Cannot delete an unsaved {self.__class__.__name__} instance"
138
+ )
139
+
140
+ query = Query(self.__class__)
141
+ query.query_type = "DELETE"
142
+ query.pk_value = pk_val
143
+ QueryPipeline(connection=get_connection()).run(query)
144
+
145
+ if pk_name:
146
+ object.__setattr__(self, pk_name, None)
147
+
148
+ @classmethod
149
+ def create_table(cls) -> None:
150
+ """Execute ``CREATE TABLE IF NOT EXISTS`` for this model."""
151
+ col_defs = []
152
+ for field_name, field_obj in cls._fields.items():
153
+ col_def = f"{field_name} {field_obj.get_sql_type()}"
154
+ if field_obj.primary_key:
155
+ col_def += " PRIMARY KEY AUTOINCREMENT"
156
+ elif not field_obj.null:
157
+ col_def += " NOT NULL"
158
+ col_defs.append(col_def)
159
+ get_connection().create_table(cls._table_name, ", ".join(col_defs))
160
+
161
+ def __eq__(self, other: object) -> bool:
162
+ if not isinstance(other, self.__class__):
163
+ return NotImplemented
164
+ pk_name, _ = self._pk_info()
165
+ if pk_name:
166
+ return getattr(self, pk_name) == getattr(other, pk_name)
167
+ return False
168
+
169
+ def __repr__(self) -> str:
170
+ attrs = ", ".join(
171
+ f"{k}={getattr(self, k, None)!r}" for k in self._fields
172
+ )
173
+ return f"{self.__class__.__name__}({attrs})"