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.
- django_datalog/__init__.py +9 -0
- django_datalog/apps.py +9 -0
- django_datalog/facts.py +164 -0
- django_datalog/migrations/__init__.py +0 -0
- django_datalog/models.py +30 -0
- django_datalog/query.py +394 -0
- django_datalog/rules.py +209 -0
- django_datalog-0.1.0.dist-info/METADATA +324 -0
- django_datalog-0.1.0.dist-info/RECORD +11 -0
- django_datalog-0.1.0.dist-info/WHEEL +4 -0
- django_datalog-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
django_datalog/facts.py
ADDED
|
@@ -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
|
django_datalog/models.py
ADDED
|
@@ -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
|
+
]
|
django_datalog/query.py
ADDED
|
@@ -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
|
django_datalog/rules.py
ADDED
|
@@ -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,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.
|