django-datalog 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.
@@ -0,0 +1,9 @@
1
+ # Package for Datalog-like fact & inference engine
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ default_app_config = "django_datalog.apps.DjangoDatalogConfig"
6
+
7
+ # Note: Core functionality (Fact, Var, query, etc.) is available in django_datalog.models
8
+ # Import them directly from there after Django is configured:
9
+ # from django_datalog.models import Fact, Var, query, store_facts, retract_facts
django_datalog/apps.py ADDED
@@ -0,0 +1,9 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoDatalogConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "django_datalog"
7
+
8
+ def ready(self):
9
+ pass
@@ -0,0 +1,164 @@
1
+ """
2
+ Fact system for djdatalog - handles fact definitions, storage, and retrieval.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, ClassVar
7
+
8
+ import uuid6
9
+ from django.db import models
10
+
11
+
12
+ class FactModel(models.Model):
13
+ """Abstract base model for storing datalog facts."""
14
+
15
+ id = models.UUIDField(primary_key=True, default=uuid6.uuid7, editable=False)
16
+
17
+ class Meta:
18
+ abstract = True
19
+ unique_together = (("subject", "object"),)
20
+
21
+
22
+ @dataclass(eq=False) # Disable auto-generated __eq__
23
+ class Fact:
24
+ """Base class for all datalog facts."""
25
+
26
+ subject: Any
27
+ object: Any
28
+ _django_model: ClassVar[type[models.Model]]
29
+
30
+ def __hash__(self):
31
+ """Make facts hashable for use in sets."""
32
+ # Use PKs for Django models, actual values for other types
33
+ subject_key = getattr(self.subject, "pk", self.subject)
34
+ object_key = getattr(self.object, "pk", self.object)
35
+ return hash((type(self), subject_key, object_key))
36
+
37
+ def __eq__(self, other):
38
+ """Compare facts for equality."""
39
+ if not isinstance(other, type(self)):
40
+ return False
41
+
42
+ # Use PKs for Django models, actual values for other types
43
+ subject_key = getattr(self.subject, "pk", self.subject)
44
+ other_subject_key = getattr(other.subject, "pk", other.subject)
45
+ object_key = getattr(self.object, "pk", self.object)
46
+ other_object_key = getattr(other.object, "pk", other.object)
47
+
48
+ return subject_key == other_subject_key and object_key == other_object_key
49
+
50
+ def __init_subclass__(cls, **kwargs):
51
+ """Automatically generate Django models for fact storage and apply dataclass decorator."""
52
+ super().__init_subclass__(**kwargs)
53
+
54
+ # Apply dataclass decorator with unsafe_hash=True to the subclass
55
+ cls = dataclass(unsafe_hash=True)(cls)
56
+
57
+ cls._django_model = cls._create_django_model()
58
+
59
+ @classmethod
60
+ def _create_django_model(cls):
61
+ """Dynamically create a Django model for this fact type."""
62
+ import sys
63
+ from typing import get_type_hints
64
+
65
+ from django.db import models
66
+
67
+ # Generate model name
68
+ model_name = f"{cls.__name__}Storage"
69
+
70
+ # Get type annotations from the class
71
+ try:
72
+ type_hints = get_type_hints(cls)
73
+ except (NameError, AttributeError):
74
+ # Fallback to raw annotations if get_type_hints fails
75
+ type_hints = getattr(cls, "__annotations__", {})
76
+
77
+ if "subject" not in type_hints or "object" not in type_hints:
78
+ raise ValueError(f"Fact {cls.__name__} must have subject and object type annotations")
79
+
80
+ # Extract Django model types from Union annotations
81
+ subject_model = cls._extract_django_model_from_annotation(type_hints["subject"])
82
+ object_model = cls._extract_django_model_from_annotation(type_hints["object"])
83
+
84
+ if not subject_model or not object_model:
85
+ raise ValueError(
86
+ f"Could not extract Django model types from {cls.__name__} annotations"
87
+ )
88
+
89
+ # Create Django model fields
90
+ model_fields = {
91
+ "subject": models.ForeignKey(subject_model, on_delete=models.CASCADE, related_name="+"),
92
+ "object": models.ForeignKey(object_model, on_delete=models.CASCADE, related_name="+"),
93
+ "__module__": cls.__module__,
94
+ }
95
+
96
+ # Create the Django model class
97
+ django_model = type(model_name, (FactModel,), model_fields)
98
+
99
+ # Inject the model into the fact's module so Django can find it
100
+ fact_module = sys.modules[cls.__module__]
101
+ setattr(fact_module, model_name, django_model)
102
+
103
+ return django_model
104
+
105
+ @classmethod
106
+ def _extract_django_model_from_annotation(cls, type_annotation):
107
+ """Extract Django model type from Union annotation like 'User | Var'."""
108
+ if hasattr(type_annotation, "__args__"):
109
+ # Handle Union types (User | Var)
110
+ for arg_type in type_annotation.__args__:
111
+ if hasattr(arg_type, "_meta") and hasattr(arg_type._meta, "app_label"):
112
+ return arg_type
113
+ elif hasattr(type_annotation, "_meta") and hasattr(type_annotation._meta, "app_label"):
114
+ # Direct Django model reference
115
+ return type_annotation
116
+ return None
117
+
118
+
119
+ def store_facts(*facts: Fact) -> None:
120
+ """Store facts in the database."""
121
+ if not facts:
122
+ return
123
+
124
+ # Group facts by type for batch operations
125
+ facts_by_type = {}
126
+ for fact in facts:
127
+ fact_type = type(fact)
128
+ if fact_type not in facts_by_type:
129
+ facts_by_type[fact_type] = []
130
+ facts_by_type[fact_type].append(fact)
131
+
132
+ # Bulk create for each fact type
133
+ for fact_type, fact_list in facts_by_type.items():
134
+ django_model = fact_type._django_model
135
+ model_instances = []
136
+
137
+ for fact in fact_list:
138
+ # Store Django model instances directly in ForeignKey fields
139
+ model_instances.append(django_model(subject=fact.subject, object=fact.object))
140
+
141
+ # Use ignore_conflicts to handle duplicates
142
+ django_model.objects.bulk_create(model_instances, ignore_conflicts=True)
143
+
144
+
145
+ def retract_facts(*facts: Fact) -> None:
146
+ """Remove facts from the database."""
147
+ if not facts:
148
+ return
149
+
150
+ # Group facts by type for batch operations
151
+ facts_by_type = {}
152
+ for fact in facts:
153
+ fact_type = type(fact)
154
+ if fact_type not in facts_by_type:
155
+ facts_by_type[fact_type] = []
156
+ facts_by_type[fact_type].append(fact)
157
+
158
+ # Batch delete for each fact type
159
+ for fact_type, fact_list in facts_by_type.items():
160
+ django_model = fact_type._django_model
161
+
162
+ for fact in fact_list:
163
+ # Delete using Django model instances directly
164
+ django_model.objects.filter(subject=fact.subject, object=fact.object).delete()
File without changes
@@ -0,0 +1,30 @@
1
+ """
2
+ Datalog system for Django - main module with public API.
3
+
4
+ This module provides the public interface for django-datalog, importing from
5
+ specialized modules for facts, queries, and rules.
6
+ """
7
+
8
+ # Public API imports
9
+ from django_datalog.facts import Fact, retract_facts, store_facts
10
+ from django_datalog.query import Var, _fact_to_django_query, _prefix_q_object, query
11
+ from django_datalog.rules import Rule, get_rules, rule
12
+
13
+ # django_datalog is a library package - storage models should be defined by consuming applications
14
+
15
+ # Re-export for backward compatibility and public API
16
+ __all__ = [
17
+ # Core classes
18
+ "Fact",
19
+ "Var",
20
+ "Rule",
21
+ # Core functions
22
+ "query",
23
+ "store_facts",
24
+ "retract_facts",
25
+ "rule",
26
+ "get_rules",
27
+ # Internal functions (exposed for testing)
28
+ "_prefix_q_object",
29
+ "_fact_to_django_query",
30
+ ]
@@ -0,0 +1,394 @@
1
+ """
2
+ Query system for djdatalog - handles querying facts with inference and optimization.
3
+ """
4
+
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from django.db.models import Q
10
+
11
+ from django_datalog.facts import Fact
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class Var:
16
+ """Variable placeholder for datalog queries."""
17
+
18
+ name: str
19
+ where: Any = None # Q object for additional constraints
20
+
21
+ def __repr__(self):
22
+ if self.where is not None:
23
+ return f"Var({self.name!r}, where={self.where!r})"
24
+ return f"Var({self.name!r})"
25
+
26
+
27
+ def query(*fact_patterns: Fact, hydrate: bool = True) -> Iterator[dict[str, Any]]:
28
+ """
29
+ Query facts from the database and apply inference rules.
30
+
31
+ Args:
32
+ *fact_patterns: One or more fact patterns to match as a conjunction
33
+ hydrate: If True (default), returns full model instances. If False, returns PKs only.
34
+
35
+ Yields:
36
+ Dictionary mapping variable names to their values (models or PKs based on hydrate)
37
+ """
38
+ # Get all available facts (both stored and inferred)
39
+ all_facts = _get_all_facts_with_inference()
40
+
41
+ # Get the conjunction results using both stored and inferred facts
42
+ pk_results = _satisfy_conjunction_with_facts(list(fact_patterns), {}, all_facts)
43
+
44
+ if hydrate:
45
+ # Collect all results first to batch hydration
46
+ pk_results_list = list(pk_results)
47
+ # Hydrate PKs to model instances
48
+ yield from _hydrate_results(pk_results_list, list(fact_patterns))
49
+ else:
50
+ # Return PKs directly without hydration
51
+ yield from pk_results
52
+
53
+
54
+ def _get_all_facts_with_inference() -> list[Fact]:
55
+ """Get all facts including both stored facts and inferred facts from rules."""
56
+ from django_datalog.rules import apply_rules
57
+
58
+ # Get all stored facts from the database
59
+ stored_facts = _get_all_stored_facts()
60
+
61
+ # Apply inference rules to derive new facts
62
+ all_facts = apply_rules(stored_facts)
63
+
64
+ return all_facts
65
+
66
+
67
+ def _get_all_stored_facts() -> list[Fact]:
68
+ """Get all facts currently stored in the database."""
69
+ from django_datalog.rules import get_rules
70
+
71
+ stored_facts = []
72
+
73
+ # Get all fact types used in rules to know what to load
74
+ fact_types = set()
75
+ for rule in get_rules():
76
+ fact_types.add(type(rule.head))
77
+ for condition in rule.body:
78
+ fact_types.add(type(condition))
79
+
80
+ # Load facts of all relevant types from the database
81
+ for fact_type in fact_types:
82
+ try:
83
+ django_model = fact_type._django_model
84
+ # Get subject and object type from fact type annotations
85
+ subject_type, object_type = _get_fact_field_types(fact_type)
86
+
87
+ # Load facts using select_related for efficient loading
88
+ for row in django_model.objects.select_related("subject", "object").all():
89
+ try:
90
+ # Access the related Django model instances directly
91
+ fact = fact_type(subject=row.subject, object=row.object)
92
+ stored_facts.append(fact)
93
+ except Exception:
94
+ # Skip facts that can't be loaded
95
+ continue
96
+ except Exception:
97
+ # Skip fact types that can't be loaded
98
+ continue
99
+
100
+ return stored_facts
101
+
102
+
103
+ def _get_fact_field_types(fact_type):
104
+ """Get the Django model types for subject and object fields of a fact type."""
105
+ # Use the cached model types from the Fact class
106
+ if hasattr(fact_type, "_model_types_cache"):
107
+ cache = fact_type._model_types_cache
108
+ return cache.get("subject"), cache.get("object")
109
+
110
+ # Fallback: extract from type annotations
111
+ from dataclasses import fields
112
+
113
+ fact_fields = fields(fact_type)
114
+ subject_field = next((f for f in fact_fields if f.name == "subject"), None)
115
+ object_field = next((f for f in fact_fields if f.name == "object"), None)
116
+
117
+ if not subject_field or not object_field:
118
+ raise ValueError(f"Fact type {fact_type} missing subject or object field")
119
+
120
+ # Extract Django model types from Union annotations (like Person | Var)
121
+ subject_type = _extract_model_type_from_annotation(subject_field.type)
122
+ object_type = _extract_model_type_from_annotation(object_field.type)
123
+
124
+ return subject_type, object_type
125
+
126
+
127
+ def _extract_model_type_from_annotation(type_annotation):
128
+ """Extract Django model type from type annotation like 'Person | Var'."""
129
+ if hasattr(type_annotation, "__args__"):
130
+ # Handle Union types (Person | Var)
131
+ for arg_type in type_annotation.__args__:
132
+ if hasattr(arg_type, "_meta") and hasattr(arg_type._meta, "app_label"):
133
+ # This looks like a Django model
134
+ return arg_type
135
+ elif hasattr(type_annotation, "_meta") and hasattr(type_annotation._meta, "app_label"):
136
+ # Direct Django model reference
137
+ return type_annotation
138
+
139
+ return None
140
+
141
+
142
+ def _satisfy_conjunction_with_facts(
143
+ conditions, bindings, all_facts: list[Fact]
144
+ ) -> Iterator[dict[str, Any]]:
145
+ """Satisfy a conjunction of conditions using the combined set of stored and inferred facts."""
146
+ if not conditions:
147
+ yield bindings
148
+ return
149
+
150
+ # Take the first condition
151
+ condition = conditions[0]
152
+ remaining = conditions[1:]
153
+
154
+ # Query against the complete set of facts (stored + inferred) to avoid duplication
155
+ for result in _query_against_facts(condition, all_facts):
156
+ new_bindings = _unify_bindings(bindings, result)
157
+ if new_bindings is not None:
158
+ yield from _satisfy_conjunction_with_facts(remaining, new_bindings, all_facts)
159
+
160
+
161
+ def _query_against_facts(pattern: Fact, facts: list[Fact]) -> Iterator[dict[str, Any]]:
162
+ """Query a pattern against a set of in-memory facts."""
163
+ pattern_type = type(pattern)
164
+
165
+ for fact in facts:
166
+ if type(fact) is pattern_type:
167
+ # Try to unify the pattern with this fact
168
+ substitution = _unify_fact_pattern(pattern, fact)
169
+ if substitution is not None:
170
+ yield substitution
171
+
172
+
173
+ def _unify_fact_pattern(pattern: Fact, concrete_fact: Fact) -> dict[str, Any] | None:
174
+ """Unify a fact pattern (with variables) against a concrete fact."""
175
+ substitution = {}
176
+
177
+ # Check subject
178
+ if isinstance(pattern.subject, Var):
179
+ # Check if subject meets the variable's constraints
180
+ if pattern.subject.where is not None:
181
+ if not _check_q_constraint(concrete_fact.subject, pattern.subject.where):
182
+ return None # Subject doesn't meet constraints
183
+
184
+ substitution[pattern.subject.name] = (
185
+ concrete_fact.subject.pk
186
+ if hasattr(concrete_fact.subject, "pk")
187
+ else concrete_fact.subject
188
+ )
189
+ elif pattern.subject != concrete_fact.subject:
190
+ return None # Subjects don't match
191
+
192
+ # Check object
193
+ if isinstance(pattern.object, Var):
194
+ # Check if object meets the variable's constraints
195
+ if pattern.object.where is not None:
196
+ if not _check_q_constraint(concrete_fact.object, pattern.object.where):
197
+ return None # Object doesn't meet constraints
198
+
199
+ var_name = pattern.object.name
200
+ obj_value = (
201
+ concrete_fact.object.pk if hasattr(concrete_fact.object, "pk") else concrete_fact.object
202
+ )
203
+ # Check for conflicting bindings
204
+ if var_name in substitution and substitution[var_name] != obj_value:
205
+ return None
206
+ substitution[var_name] = obj_value
207
+ elif pattern.object != concrete_fact.object:
208
+ return None # Objects don't match
209
+
210
+ return substitution
211
+
212
+
213
+ def _check_q_constraint(model_instance, q_constraint) -> bool:
214
+ """Check if a model instance satisfies a Q constraint."""
215
+ # Convert the Q constraint to a filter and check if the instance matches
216
+ try:
217
+ # Create a queryset for this model and apply the constraint
218
+ queryset = model_instance.__class__.objects.filter(q_constraint)
219
+ # Check if this specific instance matches the constraint
220
+ return queryset.filter(pk=model_instance.pk).exists()
221
+ except Exception:
222
+ # If there's any error with the constraint check, assume it fails
223
+ return False
224
+
225
+
226
+ def _satisfy_conjunction(conditions, bindings) -> Iterator[dict[str, Any]]:
227
+ """Satisfy a conjunction of conditions with the given variable bindings."""
228
+ if not conditions:
229
+ yield bindings
230
+ return
231
+
232
+ # Take the first condition
233
+ condition = conditions[0]
234
+ remaining = conditions[1:]
235
+
236
+ # Generate all possible solutions for this condition
237
+ for result in _query_single_fact(condition):
238
+ # Try to unify with existing bindings
239
+ new_bindings = _unify_bindings(bindings, result)
240
+ if new_bindings is not None:
241
+ # Recursively solve remaining conditions
242
+ yield from _satisfy_conjunction(remaining, new_bindings)
243
+
244
+
245
+ def _query_single_fact(fact_pattern: Fact) -> Iterator[dict[str, Any]]:
246
+ """Query a single fact pattern from the database."""
247
+ # Get the Django model for this fact type
248
+ fact_class = type(fact_pattern)
249
+ django_model = fact_class._django_model
250
+
251
+ # Convert fact pattern to Django query
252
+ query_params, q_objects = _fact_to_django_query(fact_pattern)
253
+
254
+ # Build the queryset with both filter params and Q objects
255
+ queryset = django_model.objects.filter(**query_params)
256
+ for q_obj in q_objects:
257
+ queryset = queryset.filter(q_obj)
258
+
259
+ # Query the database with values() to get PKs
260
+ for values_dict in queryset.values("subject", "object"):
261
+ substitution = _django_result_to_substitution(fact_pattern, values_dict)
262
+ yield substitution
263
+
264
+
265
+ def _fact_to_django_query(fact: Fact) -> tuple[dict[str, Any], list[Any]]:
266
+ """
267
+ Convert a fact to Django query parameters and Q objects.
268
+
269
+ Returns:
270
+ tuple: (query_params, q_objects) where q_objects are constraints for Vars
271
+ """
272
+ query_params = {}
273
+ q_objects = []
274
+
275
+ if not isinstance(fact.subject, Var):
276
+ # Use Django model instance directly for ForeignKey lookup
277
+ query_params["subject"] = fact.subject
278
+ elif fact.subject.where is not None:
279
+ # Add Q object constraint with subject__ prefix
280
+ prefixed_q = _prefix_q_object(fact.subject.where, "subject")
281
+ q_objects.append(prefixed_q)
282
+
283
+ if not isinstance(fact.object, Var):
284
+ # Use Django model instance directly for ForeignKey lookup
285
+ query_params["object"] = fact.object
286
+ elif fact.object.where is not None:
287
+ # Add Q object constraint with object__ prefix
288
+ prefixed_q = _prefix_q_object(fact.object.where, "object")
289
+ q_objects.append(prefixed_q)
290
+
291
+ return query_params, q_objects
292
+
293
+
294
+ def _prefix_q_object(q_obj, prefix: str):
295
+ """Prefix all field lookups in a Q object with the given prefix."""
296
+ if hasattr(q_obj, "children"):
297
+ # Q object with children (AND/OR operations)
298
+ new_q = Q()
299
+ new_q.connector = q_obj.connector
300
+ new_q.negated = q_obj.negated
301
+
302
+ for child in q_obj.children:
303
+ if isinstance(child, tuple):
304
+ # This is a field lookup: (field_name, value)
305
+ field_name, value = child
306
+ new_field_name = f"{prefix}__{field_name}"
307
+ new_q.children.append((new_field_name, value))
308
+ else:
309
+ # This is another Q object - recurse
310
+ new_q.children.append(_prefix_q_object(child, prefix))
311
+ return new_q
312
+ else:
313
+ # Simple Q object - create a new one with prefixed fields
314
+ new_q = Q()
315
+ new_q.connector = q_obj.connector
316
+ new_q.negated = q_obj.negated
317
+ for child in q_obj.children:
318
+ if isinstance(child, tuple):
319
+ field_name, value = child
320
+ new_field_name = f"{prefix}__{field_name}"
321
+ new_q.children.append((new_field_name, value))
322
+ return new_q
323
+
324
+
325
+ def _django_result_to_substitution(fact: Fact, values_dict: dict) -> dict[str, Any]:
326
+ """Convert Django query result to variable substitution."""
327
+ substitution = {}
328
+ if isinstance(fact.subject, Var):
329
+ # values_dict contains PKs from ForeignKey fields
330
+ substitution[fact.subject.name] = values_dict["subject"]
331
+ if isinstance(fact.object, Var):
332
+ # values_dict contains PKs from ForeignKey fields
333
+ substitution[fact.object.name] = values_dict["object"]
334
+ return substitution
335
+
336
+
337
+ def _unify_bindings(existing: dict, new: dict) -> dict[str, Any] | None:
338
+ """Try to unify two sets of variable bindings."""
339
+ result = existing.copy()
340
+ for var, value in new.items():
341
+ if var in result:
342
+ if result[var] != value:
343
+ return None # Conflict
344
+ else:
345
+ result[var] = value
346
+ return result
347
+
348
+
349
+ def _hydrate_results(pk_results: list[dict], fact_patterns: list[Fact]) -> Iterator[dict[str, Any]]:
350
+ """Hydrate PK results to full model instances."""
351
+ if not pk_results:
352
+ return
353
+
354
+ # Collect all PKs that need hydration by variable name and model type
355
+ var_to_model_type = {}
356
+ pks_to_hydrate = {}
357
+
358
+ # First pass: discover what models each variable represents
359
+ for fact_pattern in fact_patterns:
360
+ for field_name in ["subject", "object"]:
361
+ field_val = getattr(fact_pattern, field_name)
362
+ if isinstance(field_val, Var):
363
+ var_name = field_val.name
364
+ # Extract model type from fact pattern using the helper function
365
+ model_type = _extract_model_type_from_annotation(
366
+ fact_pattern.__dataclass_fields__[field_name].type
367
+ )
368
+ if model_type and var_name not in var_to_model_type:
369
+ var_to_model_type[var_name] = model_type
370
+ pks_to_hydrate[var_name] = set()
371
+
372
+ # Second pass: collect all PKs for each variable
373
+ for result in pk_results:
374
+ for var_name, pk in result.items():
375
+ if var_name in pks_to_hydrate:
376
+ pks_to_hydrate[var_name].add(pk)
377
+
378
+ # Batch load models by type
379
+ model_cache = {}
380
+ for var_name, model_type in var_to_model_type.items():
381
+ if var_name in pks_to_hydrate:
382
+ pk_list = list(pks_to_hydrate[var_name])
383
+ models = model_type.objects.in_bulk(pk_list)
384
+ model_cache[var_name] = models
385
+
386
+ # Hydrate results
387
+ for result in pk_results:
388
+ hydrated_result = {}
389
+ for var_name, pk in result.items():
390
+ if var_name in model_cache and pk in model_cache[var_name]:
391
+ hydrated_result[var_name] = model_cache[var_name][pk]
392
+ else:
393
+ hydrated_result[var_name] = pk # Keep as PK if can't hydrate
394
+ yield hydrated_result
@@ -0,0 +1,209 @@
1
+ """
2
+ Rule system for djdatalog - handles inference rules and rule evaluation.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from django_datalog.facts import Fact
9
+
10
+
11
+ @dataclass
12
+ class Rule:
13
+ """Represents a datalog inference rule."""
14
+
15
+ head: Fact
16
+ body: list[Any] # List of facts or nested lists (for OR conditions)
17
+
18
+ def __repr__(self):
19
+ return f"Rule({self.head} :- {self.body})"
20
+
21
+
22
+ # Global rule registry
23
+ _rules: list[Rule] = []
24
+
25
+
26
+ def rule(head: Fact, *body) -> None:
27
+ """
28
+ Define an inference rule.
29
+
30
+ Args:
31
+ head: The fact that can be inferred
32
+ *body: Conditions that must be true (supports nested lists for OR conditions)
33
+
34
+ Example:
35
+ rule(
36
+ GrandparentOf(Var("grandparent"), Var("grandchild")),
37
+ ParentOf(Var("grandparent"), Var("parent")),
38
+ ParentOf(Var("parent"), Var("grandchild"))
39
+ )
40
+ """
41
+ new_rule = Rule(head=head, body=list(body))
42
+ _rules.append(new_rule)
43
+
44
+
45
+ def get_rules() -> list[Rule]:
46
+ """Get all registered rules."""
47
+ return _rules.copy()
48
+
49
+
50
+ def apply_rules(base_facts: list[Fact]) -> list[Fact]:
51
+ """
52
+ Apply inference rules to derive new facts from base facts.
53
+
54
+ Args:
55
+ base_facts: Set of known facts
56
+
57
+ Returns:
58
+ Set of all facts (base + inferred)
59
+ """
60
+ all_facts = base_facts[:]
61
+ changed = True
62
+ max_iterations = 100 # Prevent infinite loops
63
+ iterations = 0
64
+
65
+ while changed and iterations < max_iterations:
66
+ changed = False
67
+ iterations += 1
68
+
69
+ for rule_obj in _rules:
70
+ # Try to apply this rule
71
+ new_facts = _apply_single_rule(rule_obj, all_facts)
72
+ for new_fact in new_facts:
73
+ if new_fact not in all_facts:
74
+ all_facts.append(new_fact)
75
+ changed = True
76
+
77
+ return all_facts
78
+
79
+
80
+ def _apply_single_rule(rule_obj: Rule, known_facts: list[Fact]) -> list[Fact]:
81
+ """Apply a single rule to known facts to derive new facts."""
82
+ new_facts = []
83
+
84
+ # known_facts is already a list
85
+ fact_list = known_facts
86
+
87
+ # Try to find all possible variable bindings that satisfy the rule body
88
+ bindings_list = _find_all_bindings(rule_obj.body, fact_list)
89
+
90
+ # For each valid binding, instantiate the rule head to create a new fact
91
+ for bindings in bindings_list:
92
+ try:
93
+ new_fact = _instantiate_fact(rule_obj.head, bindings)
94
+ if new_fact and new_fact not in known_facts and new_fact not in new_facts:
95
+ new_facts.append(new_fact)
96
+ except Exception:
97
+ # Skip invalid instantiations
98
+ continue
99
+
100
+ return new_facts
101
+
102
+
103
+ def _find_all_bindings(conditions: list[Any], known_facts: list[Fact]) -> list[dict[str, Any]]:
104
+ """Find all variable bindings that satisfy all conditions."""
105
+ if not conditions:
106
+ return [{}] # Empty binding for empty conditions
107
+
108
+ all_bindings = []
109
+
110
+ # Start with the first condition
111
+ first_condition = conditions[0]
112
+ remaining_conditions = conditions[1:]
113
+
114
+ # Find all bindings for the first condition
115
+ first_bindings = _find_bindings_for_condition(first_condition, known_facts)
116
+
117
+ # For each binding of the first condition, try to extend it with remaining conditions
118
+ for binding in first_bindings:
119
+ if remaining_conditions:
120
+ # Recursively find bindings for remaining conditions
121
+ extended_bindings = _find_all_bindings(remaining_conditions, known_facts)
122
+ for ext_binding in extended_bindings:
123
+ # Merge bindings, checking for conflicts
124
+ merged = _merge_bindings(binding, ext_binding)
125
+ if merged is not None:
126
+ all_bindings.append(merged)
127
+ else:
128
+ # No more conditions, this binding is complete
129
+ all_bindings.append(binding)
130
+
131
+ return all_bindings
132
+
133
+
134
+ def _find_bindings_for_condition(condition: Any, known_facts: list[Fact]) -> list[dict[str, Any]]:
135
+ """Find all variable bindings that match a single condition against known facts."""
136
+ bindings_list = []
137
+
138
+ # Check each known fact to see if it matches the condition
139
+ for fact in known_facts:
140
+ if type(fact) is type(condition): # Same fact type
141
+ binding = _unify_facts(condition, fact)
142
+ if binding is not None:
143
+ bindings_list.append(binding)
144
+
145
+ return bindings_list
146
+
147
+
148
+ def _unify_facts(pattern_fact: Fact, concrete_fact: Fact) -> dict[str, Any] | None:
149
+ """Unify a pattern fact (with variables) against a concrete fact."""
150
+ from django_datalog.query import Var
151
+
152
+ bindings = {}
153
+
154
+ # Check subject
155
+ if isinstance(pattern_fact.subject, Var):
156
+ bindings[pattern_fact.subject.name] = concrete_fact.subject
157
+ elif pattern_fact.subject != concrete_fact.subject:
158
+ return None # Subjects don't match
159
+
160
+ # Check object
161
+ if isinstance(pattern_fact.object, Var):
162
+ var_name = pattern_fact.object.name
163
+ # Check for conflicting bindings
164
+ if var_name in bindings and bindings[var_name] != concrete_fact.object:
165
+ return None
166
+ bindings[var_name] = concrete_fact.object
167
+ elif pattern_fact.object != concrete_fact.object:
168
+ return None # Objects don't match
169
+
170
+ return bindings
171
+
172
+
173
+ def _merge_bindings(binding1: dict[str, Any], binding2: dict[str, Any]) -> dict[str, Any] | None:
174
+ """Merge two variable bindings, checking for conflicts."""
175
+ merged = binding1.copy()
176
+
177
+ for var_name, value in binding2.items():
178
+ if var_name in merged:
179
+ if merged[var_name] != value:
180
+ return None # Conflict - same variable bound to different values
181
+ else:
182
+ merged[var_name] = value
183
+
184
+ return merged
185
+
186
+
187
+ def _instantiate_fact(pattern_fact: Fact, bindings: dict[str, Any]) -> Fact | None:
188
+ """Create a concrete fact by substituting variables with their bindings."""
189
+ from django_datalog.query import Var
190
+
191
+ # Substitute subject
192
+ if isinstance(pattern_fact.subject, Var):
193
+ if pattern_fact.subject.name not in bindings:
194
+ return None # Unbound variable
195
+ subject = bindings[pattern_fact.subject.name]
196
+ else:
197
+ subject = pattern_fact.subject
198
+
199
+ # Substitute object
200
+ if isinstance(pattern_fact.object, Var):
201
+ if pattern_fact.object.name not in bindings:
202
+ return None # Unbound variable
203
+ obj = bindings[pattern_fact.object.name]
204
+ else:
205
+ obj = pattern_fact.object
206
+
207
+ # Create new fact instance of the same type
208
+ fact_class = type(pattern_fact)
209
+ return fact_class(subject=subject, object=obj)
@@ -0,0 +1,324 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-datalog
3
+ Version: 0.1.0
4
+ Summary: Django Datalog - Logic programming and inference engine for Django applications
5
+ Project-URL: Homepage, https://github.com/edelvalle/django-datalog
6
+ Project-URL: Documentation, https://django-datalog.readthedocs.io/
7
+ Project-URL: Repository, https://github.com/edelvalle/django-datalog.git
8
+ Project-URL: Issues, https://github.com/edelvalle/django-datalog/issues
9
+ Project-URL: Changelog, https://github.com/edelvalle/django-datalog/blob/main/CHANGELOG.md
10
+ Author-email: Eddy Ernesto del Valle Pino <eddy@edelvalle.me>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: datalog,django,facts,inference-engine,logic-programming,query,rules
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Web Environment
16
+ Classifier: Framework :: Django
17
+ Classifier: Framework :: Django :: 5.0
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: License :: OSI Approved :: MIT License
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python
22
+ Classifier: Programming Language :: Python :: 3
23
+ Classifier: Programming Language :: Python :: 3.10
24
+ Classifier: Programming Language :: Python :: 3.11
25
+ Classifier: Programming Language :: Python :: 3.12
26
+ Classifier: Programming Language :: Python :: 3.13
27
+ Classifier: Topic :: Internet :: WWW/HTTP
28
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
29
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
30
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
31
+ Requires-Python: >=3.10
32
+ Requires-Dist: django>=5
33
+ Requires-Dist: uuid6>=2024.1.12
34
+ Provides-Extra: dev
35
+ Requires-Dist: basedpyright>=1.12; extra == 'dev'
36
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
37
+ Requires-Dist: pytest-django>=4.5; extra == 'dev'
38
+ Requires-Dist: pytest>=7.0; extra == 'dev'
39
+ Requires-Dist: ruff>=0.1; extra == 'dev'
40
+ Provides-Extra: docs
41
+ Requires-Dist: mkdocs-material>=9.0; extra == 'docs'
42
+ Requires-Dist: mkdocs>=1.5; extra == 'docs'
43
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == 'docs'
44
+ Provides-Extra: test
45
+ Requires-Dist: pytest-cov>=4.0; extra == 'test'
46
+ Requires-Dist: pytest-django>=4.5; extra == 'test'
47
+ Requires-Dist: pytest>=7.0; extra == 'test'
48
+ Description-Content-Type: text/markdown
49
+
50
+ # django-datalog
51
+
52
+ Django Datalog - A logic programming and inference engine for Django applications.
53
+
54
+ ## Features
55
+
56
+ - **Fact-based data modeling**: Define facts as Python classes that integrate seamlessly with Django models
57
+ - **Logic programming**: Write inference rules using a familiar Python syntax
58
+ - **Query system**: Query facts and derived conclusions with variable binding
59
+ - **Q object constraints**: Filter query results using Django Q objects for powerful filtering
60
+ - **Performance optimized**: Intelligent query reordering and batch hydration for optimal performance
61
+ - **Django integration**: Seamless integration with existing Django models and ORM
62
+
63
+ ## Quick Start
64
+
65
+ ### Installation
66
+
67
+ ```bash
68
+ pip install django-datalog
69
+ ```
70
+
71
+ Add `django_datalog` to your `INSTALLED_APPS`:
72
+
73
+ ```python
74
+ INSTALLED_APPS = [
75
+ # ... your other apps
76
+ 'django_datalog',
77
+ ]
78
+ ```
79
+
80
+ Run migrations to create the datalog fact tables:
81
+
82
+ ```bash
83
+ python manage.py migrate
84
+ ```
85
+
86
+ ### Basic Example: Company Employee Management
87
+
88
+ Let's start with a realistic business scenario - managing employees in a company:
89
+
90
+ ```python
91
+ # models.py
92
+ from django.db import models
93
+ from django.contrib.auth.models import User
94
+ from django_datalog.models import Fact, Var
95
+
96
+ class Company(models.Model):
97
+ name = models.CharField(max_length=100)
98
+
99
+ class Department(models.Model):
100
+ name = models.CharField(max_length=100)
101
+ company = models.ForeignKey(Company, on_delete=models.CASCADE)
102
+
103
+ class Employee(models.Model):
104
+ user = models.OneToOneField(User, on_delete=models.CASCADE)
105
+ company = models.ForeignKey(Company, on_delete=models.CASCADE)
106
+ department = models.ForeignKey(Department, on_delete=models.CASCADE)
107
+ is_manager = models.BooleanField(default=False)
108
+
109
+ # Define Facts - Note: No @dataclass decorator needed!
110
+ class WorksFor(Fact):
111
+ """Employee works for a company."""
112
+ subject: Employee | Var # Employee
113
+ object: Company | Var # Company
114
+
115
+ class MemberOf(Fact):
116
+ """Employee is member of a department."""
117
+ subject: Employee | Var # Employee
118
+ object: Department | Var # Department
119
+
120
+ class ColleaguesOf(Fact):
121
+ """Two employees are colleagues (inferred from working at same company)."""
122
+ subject: Employee | Var # Employee 1
123
+ object: Employee | Var # Employee 2
124
+
125
+ class TeamMates(Fact):
126
+ """Two employees are teammates (inferred from same department)."""
127
+ subject: Employee | Var # Employee 1
128
+ object: Employee | Var # Employee 2
129
+ ```
130
+
131
+ ```python
132
+ # rules.py - Define inference logic
133
+ from django_datalog.rules import rule
134
+ from django_datalog.models import Var
135
+ from .models import WorksFor, MemberOf, ColleaguesOf, TeamMates
136
+
137
+ # Rule: Employees are colleagues if they work for the same company
138
+ rule(
139
+ ColleaguesOf(Var("emp1"), Var("emp2")),
140
+ WorksFor(Var("emp1"), Var("company")),
141
+ WorksFor(Var("emp2"), Var("company")),
142
+ )
143
+
144
+ # Rule: Employees are teammates if they work in the same department
145
+ rule(
146
+ TeamMates(Var("emp1"), Var("emp2")),
147
+ MemberOf(Var("emp1"), Var("department")),
148
+ MemberOf(Var("emp2"), Var("department")),
149
+ )
150
+ ```
151
+
152
+ ```python
153
+ # Usage in views.py or management commands
154
+ from django_datalog.models import query, store_facts
155
+ from django.db.models import Q
156
+
157
+ # Store some facts
158
+ tech_corp = Company.objects.create(name="Tech Corp")
159
+ engineering = Department.objects.create(name="Engineering", company=tech_corp)
160
+
161
+ alice_user = User.objects.create(username="alice")
162
+ bob_user = User.objects.create(username="bob")
163
+
164
+ alice = Employee.objects.create(user=alice_user, company=tech_corp, department=engineering, is_manager=True)
165
+ bob = Employee.objects.create(user=bob_user, company=tech_corp, department=engineering)
166
+
167
+ # Store facts about work relationships
168
+ store_facts(
169
+ WorksFor(subject=alice, object=tech_corp),
170
+ WorksFor(subject=bob, object=tech_corp),
171
+ MemberOf(subject=alice, object=engineering),
172
+ MemberOf(subject=bob, object=engineering),
173
+ )
174
+
175
+ # Query: Find all of Alice's colleagues (inferred automatically!)
176
+ colleagues = list(query(ColleaguesOf(alice, Var("colleague"))))
177
+ for result in colleagues:
178
+ colleague = result["colleague"]
179
+ print(f"Alice works with {colleague.user.username}")
180
+
181
+ # Query: Find all teammates in engineering
182
+ teammates = list(query(TeamMates(Var("emp1"), Var("emp2"))))
183
+ for result in teammates:
184
+ emp1, emp2 = result["emp1"], result["emp2"]
185
+ print(f"{emp1.user.username} and {emp2.user.username} are teammates")
186
+
187
+ # Query with constraints: Find managers who work for Tech Corp
188
+ managers = list(query(WorksFor(Var("employee", where=Q(is_manager=True)), tech_corp)))
189
+ for result in managers:
190
+ manager = result["employee"]
191
+ print(f"{manager.user.username} is a manager at {tech_corp.name}")
192
+ ```
193
+
194
+ ## Advanced Features
195
+
196
+ ### Q Object Constraints
197
+
198
+ Use Django Q objects to add powerful filtering to your queries:
199
+
200
+ ```python
201
+ from django.db.models import Q
202
+
203
+ # Find senior employees (with complex constraints)
204
+ senior_employees = query(
205
+ WorksFor(
206
+ Var("employee", where=Q(user__date_joined__year__lt=2020) & Q(is_manager=True)),
207
+ Var("company")
208
+ )
209
+ )
210
+
211
+ # Find employees in specific departments
212
+ engineering_employees = query(
213
+ MemberOf(Var("employee"), Var("dept", where=Q(name__icontains="engineering")))
214
+ )
215
+ ```
216
+
217
+ ### Hydration Control
218
+
219
+ Control whether to fetch full model instances or just PKs for better performance:
220
+
221
+ ```python
222
+ # Get full objects (default) - includes all related data
223
+ results = list(query(WorksFor(Var("employee"), tech_corp), hydrate=True))
224
+ employee = results[0]["employee"]
225
+ print(employee.user.username) # Full Employee object with related User
226
+
227
+ # Get PKs only (better performance for large datasets)
228
+ results = list(query(WorksFor(Var("employee"), tech_corp), hydrate=False))
229
+ employee_id = results[0]["employee"] # Just the employee ID (integer)
230
+ ```
231
+
232
+ ### Family Relationships Example
233
+
234
+ Django-datalog also works great for modeling family relationships:
235
+
236
+ ```python
237
+ class Person(models.Model):
238
+ name = models.CharField(max_length=100)
239
+ age = models.IntegerField()
240
+
241
+ class ParentOf(Fact):
242
+ """Person is parent of another person."""
243
+ subject: Person | Var # Parent
244
+ object: Person | Var # Child
245
+
246
+ class GrandparentOf(Fact):
247
+ """Person is grandparent of another person (inferred)."""
248
+ subject: Person | Var # Grandparent
249
+ object: Person | Var # Grandchild
250
+
251
+ class SiblingOf(Fact):
252
+ """Person is sibling of another person (inferred)."""
253
+ subject: Person | Var # Sibling 1
254
+ object: Person | Var # Sibling 2
255
+
256
+ # Define family rules
257
+ rule(
258
+ GrandparentOf(Var("grandparent"), Var("grandchild")),
259
+ ParentOf(Var("grandparent"), Var("parent")),
260
+ ParentOf(Var("parent"), Var("grandchild")),
261
+ )
262
+
263
+ rule(
264
+ SiblingOf(Var("person1"), Var("person2")),
265
+ ParentOf(Var("parent"), Var("person1")),
266
+ ParentOf(Var("parent"), Var("person2")),
267
+ )
268
+
269
+ # Usage
270
+ john = Person.objects.create(name="John", age=65)
271
+ alice = Person.objects.create(name="Alice", age=40)
272
+ bob = Person.objects.create(name="Bob", age=15)
273
+
274
+ store_facts(
275
+ ParentOf(subject=john, object=alice),
276
+ ParentOf(subject=alice, object=bob)
277
+ )
278
+
279
+ # Query: Find Bob's grandparents (automatically inferred!)
280
+ grandparents = list(query(GrandparentOf(Var("grandparent"), bob)))
281
+ print(f"Bob's grandparent: {grandparents[0]['grandparent'].name}") # John
282
+ ```
283
+
284
+ ## Key Benefits
285
+
286
+ - **No complex SQL**: Express complex relationship logic in simple Python rules
287
+ - **Automatic inference**: New facts are derived automatically from your rules
288
+ - **Performance optimized**: Query reordering and batch loading for optimal performance
289
+ - **Django native**: Works seamlessly with your existing Django models and admin
290
+ - **Type safe**: Full type hints and IDE support with modern Python syntax
291
+
292
+ ## Fact Retraction
293
+
294
+ Remove facts when relationships change:
295
+
296
+ ```python
297
+ from django_datalog.models import retract_facts
298
+
299
+ # Remove a work relationship
300
+ retract_facts(WorksFor(subject=alice, object=tech_corp))
301
+
302
+ # The system will automatically update inferred facts like ColleaguesOf
303
+ ```
304
+
305
+ ## Requirements
306
+
307
+ - Python 3.10+
308
+ - Django 5.0+
309
+
310
+ ## Documentation
311
+
312
+ For detailed examples, API reference, and advanced usage, see the [repository documentation](https://github.com/edelvalle/django-datalog) and [CHANGELOG.md](CHANGELOG.md).
313
+
314
+ ## Contributing
315
+
316
+ We welcome contributions! Please see our repository on [GitHub](https://github.com/edelvalle/django-datalog) for details.
317
+
318
+ ## License
319
+
320
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
321
+
322
+ ## Changelog
323
+
324
+ See [CHANGELOG.md](CHANGELOG.md) for version history and changes.
@@ -0,0 +1,11 @@
1
+ django_datalog/__init__.py,sha256=h27hUO5klSo0l8YsGys_NiNhErkDueb-wmaV9762Y4k,374
2
+ django_datalog/apps.py,sha256=ConQakBjws19HPiCCnzpteivsaygVMFCb4Sdfhy5Awg,194
3
+ django_datalog/facts.py,sha256=am7GwmlVg9M7tQL78OkS8BAvIZQw9BxZrsuJc1_ug40,5902
4
+ django_datalog/models.py,sha256=68UJkhbed-FZs-kZY_6JOqn1QbKBRk9FZJMxeV7L77o,848
5
+ django_datalog/query.py,sha256=oyXD8j-V88R_Wbq_If3uxRIXtw0_DAsBfZMqdvJ-cDU,14785
6
+ django_datalog/rules.py,sha256=hKKkmQV_T8P--4KIvJkXEwmA6Q4PriZyohRdowIvO1I,6626
7
+ django_datalog/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ django_datalog-0.1.0.dist-info/METADATA,sha256=Ip4z9uSsvtDd8rUQXgnTsgmniPsnMsFGWrHcmrGg_N4,10805
9
+ django_datalog-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ django_datalog-0.1.0.dist-info/licenses/LICENSE,sha256=GTVQl3vH6ht70wJXKC0yMT8CmXKHxv_YyO_utAgm7EA,1065
11
+ django_datalog-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Your Name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.