fix: index literal objects in entity table for object-based graph queries (#1042)

Literal objects (e.g. rdfs:label values) were not being inserted into the
quads_by_entity table with role='O', so queries like `tg-query-graph -o
MIL-HDBK-516D` returned no results even though the triple existed.

Removed the otype guard that skipped entity row insertion for literals in
both insert() and async_insert(). Also updated delete_collection() and
async_delete_collection() to include literal objects in entity partition
cleanup.

Existing data requires re-ingestion for old literal objects to become
queryable by object value.
This commit is contained in:
cybermaggedon 2026-07-13 21:11:01 +01:00 committed by GitHub
parent 35fc8444ec
commit f9f5a2318f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 141 additions and 27 deletions

View file

@ -99,8 +99,8 @@ class TestEntityCentricKnowledgeGraph:
# Verify batch was executed
mock_session.execute.assert_called()
def test_insert_literal_object_creates_3_entity_rows(self, entity_kg):
"""Test that inserting a quad with literal object creates 3 entity rows"""
def test_insert_literal_object_creates_4_rows(self, entity_kg):
"""Test that inserting a quad with literal object creates S+P+O entity rows + collection"""
kg, mock_session = entity_kg
mock_session.reset_mock()

View file

@ -93,8 +93,8 @@ class TestWriteAmplification:
assert 'O' in roles
assert 'G' in roles
def test_literal_object_produces_3_entity_rows(self, entity_kg):
"""Literal object → S + P entity rows (no O row) + collection row."""
def test_literal_object_produces_4_rows(self, entity_kg):
"""Literal object → S + P + O entity rows + collection row."""
kg, ctx = entity_kg
ctx["batches"].clear()
@ -109,13 +109,13 @@ class TestWriteAmplification:
batch = ctx["batches"][0]
# S + P entity rows + 1 collection = 3 (no O row for literal, no G for default)
assert len(batch._adds) == 3
# S + P + O entity rows + 1 collection = 4 (no G for default)
assert len(batch._adds) == 4
roles = [params[2] for _, params in batch._adds if len(params) == 10]
assert 'S' in roles
assert 'P' in roles
assert 'O' not in roles
assert 'O' in roles
assert 'G' not in roles
def test_triple_otype_gets_object_entity_row(self, entity_kg):
@ -208,6 +208,129 @@ class TestWriteAmplification:
assert params[9] == 'en'
# ---------------------------------------------------------------------------
# Literal object queryability: insert must index literals as entities
# ---------------------------------------------------------------------------
class TestLiteralObjectQueryability:
def test_literal_object_gets_entity_row_for_query(self, entity_kg):
"""Literal objects must get an O entity row so get_o() can find them.
Regression: inserting (s, rdfs:label, "MIL-HDBK-516D") then calling
get_o("MIL-HDBK-516D") returned nothing because no role='O' row was
written for literals.
"""
kg, ctx = entity_kg
ctx["batches"].clear()
kg.insert(
collection='col',
s='http://trustgraph.ai/e/mil-hdbk-516d',
p='http://www.w3.org/2000/01/rdf-schema#label',
o='MIL-HDBK-516D',
g=None,
otype='l',
)
batch = ctx["batches"][0]
roles = [params[2] for _, params in batch._adds if len(params) == 10]
assert 'O' in roles, "Literal objects must have role='O' entity row for get_o() queries"
o_rows = [
params for _, params in batch._adds
if len(params) == 10 and params[2] == 'O'
]
assert len(o_rows) == 1
assert o_rows[0][1] == 'MIL-HDBK-516D' # entity = object value
assert o_rows[0][4] == 'l' # otype preserved
def test_literal_object_with_dtype_and_lang_gets_entity_row(self, entity_kg):
"""Literal with datatype and language tag must also be indexed."""
kg, ctx = entity_kg
ctx["batches"].clear()
kg.insert(
collection='col',
s='http://ex.org/Alice',
p='http://www.w3.org/2000/01/rdf-schema#label',
o='Alice Smith',
g=None,
otype='l',
dtype='xsd:string',
lang='en',
)
batch = ctx["batches"][0]
roles = [params[2] for _, params in batch._adds if len(params) == 10]
assert 'O' in roles
o_rows = [
params for _, params in batch._adds
if len(params) == 10 and params[2] == 'O'
]
assert o_rows[0][8] == 'xsd:string' # dtype
assert o_rows[0][9] == 'en' # lang
def test_literal_object_default_graph_produces_4_rows(self, entity_kg):
"""Literal + default graph → S + P + O entity rows + 1 collection = 4."""
kg, ctx = entity_kg
ctx["batches"].clear()
kg.insert(
collection='col',
s='http://ex.org/s',
p='http://ex.org/label',
o='some label',
g=None,
otype='l',
)
batch = ctx["batches"][0]
assert len(batch._adds) == 4 # S + P + O + collection
def test_literal_object_non_default_graph_produces_5_rows(self, entity_kg):
"""Literal + named graph → S + P + O + G entity rows + collection = 5."""
kg, ctx = entity_kg
ctx["batches"].clear()
kg.insert(
collection='col',
s='http://ex.org/s',
p='http://ex.org/label',
o='some label',
g='http://ex.org/g1',
otype='l',
)
batch = ctx["batches"][0]
assert len(batch._adds) == 5 # S + P + O + G + collection
def test_delete_collection_includes_literal_object_entities(self, entity_kg):
"""Literal objects must be included in entity partition deletes."""
kg, ctx = entity_kg
mock_rows = [
MagicMock(d='', s='http://ex.org/A', p='http://ex.org/name',
o='Alice', otype='l', dtype='', lang=''),
]
ctx["session"].execute.return_value = mock_rows
ctx["batches"].clear()
kg.delete_collection('col')
entity_deletes = []
for batch in ctx["batches"]:
for _, params in batch._adds:
if len(params) == 2:
entity_deletes.append(params[1])
assert 'Alice' in entity_deletes, \
"Literal object entities must be deleted when collection is deleted"
# ---------------------------------------------------------------------------
# In-partition filtering: get_os, get_spo
# ---------------------------------------------------------------------------
@ -323,8 +446,8 @@ class TestDeleteCollectionBatching:
# Just verify the function completes and calls execute
assert ctx["session"].execute.called
def test_literal_objects_not_treated_as_entities(self, entity_kg):
"""Literal objects (otype='l') should not get entity partition deletes."""
def test_literal_objects_treated_as_entities(self, entity_kg):
"""Literal objects (otype='l') should get entity partition deletes."""
kg, ctx = entity_kg
mock_rows = [
@ -336,16 +459,15 @@ class TestDeleteCollectionBatching:
kg.delete_collection('col')
# Entity partition deletes should only include A and name, not Alice
entity_deletes = []
for batch in ctx["batches"]:
for _, params in batch._adds:
if len(params) == 2: # delete_entity_partition takes (collection, entity)
if len(params) == 2:
entity_deletes.append(params[1])
assert 'http://ex.org/A' in entity_deletes
assert 'http://ex.org/name' in entity_deletes
assert 'Alice' not in entity_deletes
assert 'Alice' in entity_deletes
def test_non_default_graph_treated_as_entity(self, entity_kg):
"""Non-default graphs should get entity partition deletes."""

View file

@ -776,11 +776,10 @@ class EntityCentricKnowledgeGraph:
collection, p, 'P', p, otype, s, o, g, dtype, lang
))
# Write row for object entity (role='O') - only for URIs, not literals
if otype == 'u' or otype == 't':
batch.add(self.insert_entity_stmt, (
collection, o, 'O', p, otype, s, o, g, dtype, lang
))
# Write row for object entity (role='O')
batch.add(self.insert_entity_stmt, (
collection, o, 'O', p, otype, s, o, g, dtype, lang
))
# Write row for graph entity (role='G') - only for non-default graphs
if g != DEFAULT_GRAPH:
@ -997,15 +996,10 @@ class EntityCentricKnowledgeGraph:
lang = row.lang if hasattr(row, 'lang') else ''
quads.append((d, s, p, o, otype, dtype, lang))
# Subject and predicate are always entities
entities.add(s)
entities.add(p)
entities.add(o)
# Object is an entity only for URIs
if otype == 'u' or otype == 't':
entities.add(o)
# Graph is an entity for non-default graphs
if d != DEFAULT_GRAPH:
entities.add(d)
@ -1067,8 +1061,7 @@ class EntityCentricKnowledgeGraph:
batch = BatchStatement()
batch.add(self.insert_entity_stmt, (collection, s, 'S', p, otype, s, o, g, dtype, lang))
batch.add(self.insert_entity_stmt, (collection, p, 'P', p, otype, s, o, g, dtype, lang))
if otype == 'u' or otype == 't':
batch.add(self.insert_entity_stmt, (collection, o, 'O', p, otype, s, o, g, dtype, lang))
batch.add(self.insert_entity_stmt, (collection, o, 'O', p, otype, s, o, g, dtype, lang))
if g != DEFAULT_GRAPH:
batch.add(self.insert_entity_stmt, (collection, g, 'G', p, otype, s, o, g, dtype, lang))
batch.add(self.insert_collection_stmt, (collection, g, s, p, o, otype, dtype, lang))
@ -1233,8 +1226,7 @@ class EntityCentricKnowledgeGraph:
quads.append((d, s, p, o, otype, dtype, lang))
entities.add(s)
entities.add(p)
if otype == 'u' or otype == 't':
entities.add(o)
entities.add(o)
if d != DEFAULT_GRAPH:
entities.add(d)