8000 feat: set values (#316) by adityamukho · Pull Request #15 · project-minigraf/minigraf-python · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ path = "src/uniffi_bindgen.rs"

[dependencies]
# Unreleased v3 core (format v8). Switches to minigraf = "=3.0.0" at the v3.0.0 release.
minigraf = { git = "https://github.com/project-minigraf/minigraf", rev = "5f2ba46e0c5a9954d90e52f60daaad2128d91973" }
minigraf = { git = "https://github.com/project-minigraf/minigraf", rev = "7c8c554096209ddc2bf1342b9a4eef3ef77c67c3" }
uniffi = { version = "0.31.1", features = ["cli"] }
thiserror = "2.0.18"
serde_json = "1.0.149"
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ other Python type raises `TypeError`. A missing slot raises `MiniGrafError.Api`
`[API-022]`, a slot in an attribute position `[API-023]`, and a value of the wrong kind
for its slot `[API-024]`.

A `set` or `frozenset` binds a set value, for `in?` and the other set predicates or a
set-valued attribute: `pq.execute(ids={1, 2, 3})`. Its elements are `bool`, `int`, `float`,
`str`, `None` or `uuid.UUID` (a ref). `minigraf.Value` is not hashable, so a set with keyword
elements is written `minigraf.Value.SET([minigraf.Value.KEYWORD(":role/admin")])`. A set
inside a set raises `MiniGrafError.Api` with `[API-021]`; a `list` is not a set. Set values
come back as JSON arrays from `execute()` and as `Value.SET([...])` in fact records.

## Fact log and log writer

`db.fact_log(filter)` streams every fact version (assertions and retractions, with
Expand Down
7 changes: 7 additions & 0 deletions consumer-test/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ def names(db):
assert typed == [["Alice"]], "prepared query takes a typed value"
print("ok: prepared query runs with plain and typed bind values")

mem.execute('(transact [[:alice :tags #{1 "x"}]])')
tags = json.loads(mem.execute("(query [:find ?t :where [?e :tags ?t]])"))["results"]
assert tags == [[["x", 1]]], "a set value comes back as an array"
member = mem.prepare("(query [:find ?t :where [?e :tags ?t] [(in? 1 $s)]])")
assert json.loads(member.execute(s={1, 2}))["results"] == [[["x", 1]]], "a set binds"
print("ok: set values come back and bind")

try:
mem.execute("(query [:find")
except MiniGrafError:
Expand Down
48 changes: 37 additions & 11 deletions minigraf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,32 +91,58 @@ def Timestamp(ms):
_I64_MIN, _I64_MAX = -(2**63), 2**63 - 1


def _bind_value(name, v):
"""A plain Python value or a typed form as a ``MiniGrafBindValue``."""
if isinstance(v, MiniGrafBindValue):
return v
def _plain_value(name, v):
"""A plain Python value as a ``MiniGrafValue``, or ``None`` if it is none."""
if isinstance(v, MiniGrafValue):
return MiniGrafBindValue.VALUE(v)
return v
# bool before int: True is an int in Python.
if isinstance(v, bool):
return MiniGrafBindValue.VALUE(MiniGrafValue.BOOL(v))
return MiniGrafValue.BOOL(v)
if isinstance(v, int):
if not _I64_MIN <= v <= _I64_MAX:
raise MiniGrafError.Api(
f"[API-017] invalid argument: slot ${name}: {v} is outside the 64-bit integer range"
)
return MiniGrafBindValue.VALUE(MiniGrafValue.INT64(v))
return MiniGrafValue.INT64(v)
if isinstance(v, float):
return MiniGrafBindValue.VALUE(MiniGrafValue.FLOAT64(v))
return MiniGrafValue.FLOAT64(v)
if isinstance(v, str):
return MiniGrafBindValue.VALUE(MiniGrafValue.TEXT(v))
return MiniGrafValue.TEXT(v)
if v is None:
return MiniGrafBindValue.VALUE(MiniGrafValue.NULL())
return MiniGrafValue.NULL()
if isinstance(v, (set, frozenset)):
return MiniGrafValue.SET([_set_element(name, e) for e in v])
return None


def _set_element(name, e):
"""An element of a Python set as a ``MiniGrafValue``. A UUID is a ref.
``minigraf.Value`` is not hashable, so keyword elements need
``Value.SET([...])``."""
value = _plain_value(name, e)
if value is not None:
return value
if isinstance(e, _uuid.UUID):
return MiniGrafValue.REF(str(e))
raise TypeError(
f"slot ${name}: cannot put a {type(e).__name__} in a set; use bool, int, float, "
"str, None, uuid.UUID, or Value.SET([...]) for keywords"
)


def _bind_value(name, v):
"""A plain Python value or a typed form as a ``MiniGrafBindValue``."""
if isinstance(v, MiniGrafBindValue):
return v
value = _plain_value(name, v)
if value is not None:
return MiniGrafBindValue.VALUE(value)
if isinstance(v, _uuid.UUID):
return MiniGrafBindValue.ENTITY(str(v))
raise TypeError(
f"slot ${name}: cannot bind a {type(v).__name__}; use bool, int, float, str, "
"None, uuid.UUID, a minigraf.Value or minigraf.Entity/TxCount/Timestamp/ANY_VALID_TIME"
"None, set, frozenset, uuid.UUID, a minigraf.Value or "
"minigraf.Entity/TxCount/Timestamp/ANY_VALID_TIME"
)


Expand Down
59 changes: 58 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ impl OpenOptions {
// ─── Values and fact records ─────────────────────────────────────────────────

/// A fact value with its type kept, so a record can be written back exactly.
/// `Ref` holds an entity UUID string.
/// `Ref` holds an entity UUID string. `Set` holds the elements of a set
/// value in canonical order; an element that is itself a `Set` is API-021.
#[derive(Debug, Clone, PartialEq, uniffi::Enum)]
pub enum MiniGrafValue {
Text { value: String },
Expand All @@ -208,6 +209,7 @@ pub enum MiniGrafValue {
Ref { value: String },
Keyword { value: String },
Null,
Set { value: Vec<MiniGrafValue> },
}

impl From<Value> for MiniGrafValue {
Expand All @@ -222,6 +224,14 @@ impl From<Value> for MiniGrafValue {
},
Value::Keyword(value) => MiniGrafValue::Keyword { value },
Value::Null => MiniGrafValue::Null,
Value::Set(set) => MiniGrafValue::Set {
value: set.iter().cloned().map(MiniGrafValue::from).collect(),
},
// A value kind newer than this binding: its debug form, until
// the binding learns it.
other => MiniGrafValue::Text {
value: format!("{other:?}"),
},
}
}
}
Expand All @@ -236,6 +246,12 @@ impl MiniGrafValue {
MiniGrafValue::Ref { value } => Value::Ref(parse_uuid(value, "ref value")?),
MiniGrafValue::Keyword { value } => Value::Keyword(value.clone()),
MiniGrafValue::Null => Value::Null,
MiniGrafValue::Set { value } => Value::Set(minigraf::ValueSet::new(
value
.iter()
.map(MiniGrafValue::to_core)
.collect::<Result<Vec<_>, _>>()?,
)?),
})
}
}
Expand Down Expand Up @@ -738,6 +754,9 @@ fn value_to_json(v: &Value) -> serde_json::Value {
Value::Ref(uuid) => JVal::String(uuid.to_string()),
Value::Keyword(k) => JVal::String(k.clone()),
Value::Null => JVal::Null,
Value::Set(set) => JVal::Array(set.iter().map(value_to_json).collect()),
// A value kind newer than this binding: its debug form.
other => JVal::String(format!("{other:?}")),
}
}

Expand Down Expand Up @@ -971,12 +990,50 @@ mod tests {
Value::Ref(id),
Value::Keyword(":k/w".into()),
Value::Null,
Value::Set(
minigraf::ValueSet::new([
Value::Integer(1),
Value::Keyword(":k/w".into()),
Value::Ref(id),
])
.expect("set"),
),
Value::Set(minigraf::ValueSet::default()),
] {
let back = MiniGrafValue::from(v.clone()).to_core().expect("to_core");
assert!(back == v, "value changed in round trip");
}
}

#[test]
fn set_elements_dedup_and_nesting_is_api_021() {
let MiniGrafValue::Int64 { value: 1 };
let back = MiniGrafValue::Set {
value: vec![one(), MiniGrafValue::Text { value: "a".into() }, one()],
}
.to_core()
.expect("to_core");
assert_eq!(
MiniGrafValue::from(back),
MiniGrafValue::Set {
value: vec![MiniGrafValue::Text { value: "a".into() }, one()]
},
"duplicates dropped, canonical order"
);
let e = err(MiniGrafValue::Set {
value: vec![MiniGrafValue::Set { value: vec![] }],
}
.to_core());
assert_eq!(e.code(), "API-021");
}

#[test]
fn set_is_a_json_array() {
let set =
minigraf::ValueSet::new([Value::Integer(2), Value::String("a".into())]).expect("set");
assert_eq!(value_to_json(&Value::Set(set)), serde_json::json!(["a", 2]));
}

#[test]
fn bad_uuid_is_api_017() {
let e = err(MiniGrafValue::Ref {
Expand Down
45 changes: 45 additions & 0 deletions tests/test_prepared.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,48 @@ def test_prepared_query_sees_later_writes(db):
assert rows(pq.execute(n=4)) == []
db.execute("(transact [[:d :n 4]])")
assert len(rows(pq.execute(n=4))) == 1


# ─── Sets (#316) ─────────────────────────────────────────────────────────────


def test_python_sets_bind_set_values(db):
member = db.prepare("(query [:find ?e :where [?e :n ?n] [(in? ?n $s)]])")
assert len(rows(member.execute(s={1, 3}))) == 2
assert len(rows(member.execute(s=frozenset([2])))) == 1
assert rows(member.execute(s=set())) == []
typed = Value.SET([Value.INT64(1), Value.INT64(1)])
assert len(rows(member.execute(s=typed))) == 1


def test_set_values_come_back_typed_and_as_arrays(db):
db.execute('(transact [[:a :tags #{1 :k "x"}]])')
equal = db.prepare("(query [:find ?x :where [?e :tags $s] [?e :n ?x]])")
tags = Value.SET([Value.INT64(1), Value.TEXT("x"), Value.KEYWORD(":k")])
assert rows(equal.execute(s=tags)) == [[1]]
# A string is no keyword.
assert rows(equal.execute(s={1, "x", ":k"})) == []
out = db.prepare("(query [:find ?t :where [:a :tags ?t]])")
assert rows(out.execute()) == [[["x", 1, ":k"]]]
records = [r for r in db.fact_log(minigraf.FactFilter(attributes=[":tags"]))]
assert records[0].value == Value.SET(
[Value.TEXT("x"), Value.INT64(1), Value.KEYWORD(":k")]
)


def test_set_element_forms(db):
pq = db.prepare("(query [:find ?x :where [$e :n ?x] [(in? $e $s)]])")
# A UUID element is a ref.
hit = {uuid.UUID(A)}
assert rows(pq.execute(e=minigraf.Entity(A), s=hit)) == [[1]]
with pytest.raises(MiniGrafError.Api) as e:
pq.execute(e=minigraf.Entity(A), s={frozenset([1])})
assert code(e) == "[API-021]"
with pytest.raises(MiniGrafError.Api) as e:
pq.execute(e=minigraf.Entity(A), s=Value.SET([Value.SET([])]))
assert code(e) == "[API-021]"
with pytest.raises(TypeError):
pq.execute(e=minigraf.Entity(A), s={(1, 2)})
# A list is not a set.
with pytest.raises(TypeError):
pq.execute(e=minigraf.Entity(A), s=[1])
Loading
0