From 739b51d9fe3914a6cd49a155ee3a800eeb801c86 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Fri, 6 Nov 2026 12:18:12 +0900 Subject: [PATCH 1/2] Docs: a Values and records page, with tests that pin each statement first (refs #267) Second slice of #267, rows 3 (types and datetimes), 9 (record objects) and 12 (the Schema API line). The tests come first. tests/test_values_and_records_behavior.py pins, on both builds and with SQL and wrapper objects only, how Python values go in and what comes back (scalars, the declared type winning over the Python type, Decimal, naive and aware datetime, the precision of DATETIME_SECOND, DATETIME and DATETIME_MICROS, date, bytes as signed ints, tuples, dicts, sets until the commit, an embedded document), what a query row and a record object give (methods only, no item access or attributes, to_dict is a new dict), that a record or row is the record as it was read, modify() and a stale save, identity, lookup_by_key and the Schema API next to SQL. Most statements were already covered one by one in older tests; the new file holds them in the form the page states them. The page how-to/values-and-records.adoc says only what those tests prove, and tests/test_docs_values_page.py runs every Python block of the page on both builds and checks the printed output, in the pattern of the transactions page. The nav gets one line, and Databases and queries and the quickstart link to the page. A [[record-ids-in-rows]] anchor is added to Databases and queries for the link. Tests could not run on the laptop (CI runs them), so each assertion comes from reading type_conversion.py, graph.py, results.py and the native codec, records and schema code, and from the existing tests that pin the same behavior. Co-Authored-By: Claude Sonnet 5.5 --- docs/modules/ROOT/nav.adoc | 1 + .../pages/how-to/databases-and-queries.adoc | 5 +- .../ROOT/pages/how-to/values-and-records.adoc | 422 ++++++++++++++ docs/modules/ROOT/pages/quickstart.adoc | 1 + tests/test_docs_values_page.py | 62 ++ tests/test_values_and_records_behavior.py | 537 ++++++++++++++++++ 6 files changed, 1026 insertions(+), 2 deletions(-) create mode 100644 docs/modules/ROOT/pages/how-to/values-and-records.adoc create mode 100644 tests/test_docs_values_page.py create mode 100644 tests/test_values_and_records_behavior.py diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index bdc2692..c620ea2 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -5,6 +5,7 @@ * How-to ** xref:how-to/databases-and-queries.adoc[Databases and queries] ** xref:how-to/transactions.adoc[Transactions] +** xref:how-to/values-and-records.adoc[Values and records] ** xref:how-to/concurrency.adoc[Concurrency, processes and crashes] ** xref:how-to/logs.adoc[Where the logs go] ** xref:how-to/bulk-loads.adoc[Bulk loads and commit size] diff --git a/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc b/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc index 0e27770..3039cfe 100644 --- a/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc +++ b/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc @@ -3,7 +3,7 @@ // Sources: bindings docs guide/core/database.md and guide/core/queries.md. -This guide covers the life of a database directory and the two calls that talk to it. Prefer SQL or openCypher through `db.command(...)` and `db.query(...)` for schema, inserts and graph work. The bindings also expose wrapper objects for the rest of the Java API; they are not covered here. +This guide covers the life of a database directory and the two calls that talk to it. Prefer SQL or openCypher through `db.command(...)` and `db.query(...)` for schema, inserts and graph work. The bindings also expose record objects and a `Schema` API; they are on xref:how-to/values-and-records.adoc[Values and records]. The wrappers for the rest of the Java API are not covered here. Every script on this page ran against the published `{jvm-pkg-name}` wheel. Run each one in an empty directory. @@ -246,7 +246,7 @@ A query vector for `vectorNeighbors` can be a numpy array or a list. This sectio === Results -`db.query` returns a result set you can iterate. Two methods cover the usual needs: +`db.query` returns a result set you can iterate. What a row holds, how a Python value is converted and which rows are record objects is on xref:how-to/values-and-records.adoc[Values and records]. Two methods cover the usual needs: * `first()` returns the first row, or `None` when there is none, so `if db.query(...).first():` tests whether anything matched. * `to_list()` materialises the whole result as a list of dicts. Use it when you need the whole result in memory; iterate when you stop early or the result is large. @@ -288,6 +288,7 @@ A result set is a lazy cursor, and you read it once. The engine computes the row Use `to_list()` when you need the rows twice. It reads the whole result into a list of dicts, which you can loop over as often as you like. +[[record-ids-in-rows]] === Record ids in rows A record id inside a row value (`SELECT @rid AS r`, a `LINK` property, a list of links such as `out('Knows').@rid`) comes back as an RID object, not as text. `str(rid)` is the text (`"#10:5"`), so is an f-string, and `get_rid()` always returns text or `None`. Two RIDs of the same record are equal and hash alike, so they work in sets and as dict keys, and RIDs sort by bucket, then position. `getBucketId()`, `getPosition()`, `toString()` and `isValid()` work on the object. It goes back in as a query parameter (`WHERE @rid = ?`, or a list passed by name) and to `db.lookup_by_rid()`, as itself or as its text. `to_json_list()` is the exception: a record id in its rows is the text (`"#10:5"`), on both builds, and so is a map key that is not a string, because a JSON object has no other kind of key. diff --git a/docs/modules/ROOT/pages/how-to/values-and-records.adoc b/docs/modules/ROOT/pages/how-to/values-and-records.adoc new file mode 100644 index 0000000..17b2e88 --- /dev/null +++ b/docs/modules/ROOT/pages/how-to/values-and-records.adoc @@ -0,0 +1,422 @@ += Values and records +:description: Which Python types go into a database and what comes back (datetime, date, Decimal, bytes, lists, maps, sets), what a query row and a record object give you and which of them is a copy, and the Schema API next to plain SQL. + +// Sources: src/arcadedb/type_conversion.py and _native/codec.py (values), results.py and graph.py with _native/records.py (rows and records), schema.py and _native/database.py (Schema). Every statement below is pinned by tests/test_values_and_records_behavior.py. + +This page covers what the Python bindings add to the statements: how a Python value becomes a stored value and back, what the objects you get from a query or a lookup are, and where the `Schema` API fits. The types, the date formats and the schema rules of the engine belong to the main manual: {arcadedb-docs-url}/reference/datatypes[Data types], {arcadedb-docs-url}/reference/managing-dates[Managing dates] and {arcadedb-docs-url}/concepts/schema[Schema]. + +The statements stay SQL or openCypher, as on xref:how-to/databases-and-queries.adoc[Databases and queries]. Record objects and the `Schema` API are there for the cases where Python code, not a statement, drives the work. + +The blocks on this page run one after the other against one empty database, and a test in the repository runs them that way (`tests/test_docs_values_page.py`). Each block that prints something is followed by its output. Every block assumes that `db` is an open database. Both packages give the same output. + +[source,python] +---- +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal + +from arcadedb import ArcadeDBError + +db.command("sql", "CREATE DOCUMENT TYPE Event") +db.command("sql", "CREATE PROPERTY Event.at DATETIME_MICROS") +db.command("sql", "CREATE PROPERTY Event.day DATE") +---- + +== Python values in, Python values out + +A value you bind to a parameter, pass to `set()` or give to `new_edge()` is converted for the engine, and a value you read is converted back. The conversion is the same for `get()`, `to_dict()` and `to_list()`: for one record, the three give the same Python type and the same value. + +[cols="2,2,3"] +|=== +| You give | You get back | Notes + +| `str`, `bool`, `None` +| the same +| + +| `int` +| `int` +| The engine stores 64 bits. A property declared `INTEGER` stores an `int` as that type. + +| `float` +| `float` +| A property declared `DOUBLE` turns an `int` into a `float`: `3` reads back as `3.0`. + +| `Decimal` +| `Decimal` +| Every digit is kept, so a lookup by the same `Decimal` finds the record. + +| `datetime` +| `datetime` without a time zone +| A naive value is a UTC wall clock. An aware value is converted to UTC first. + +| `date` +| `date` +| A `datetime` is not a `date`: it stays a `datetime`. + +| `bytes`, `bytearray` +| a `list` of signed `int` (-128 to 127) +| Every byte is kept. It does not come back as `bytes`. + +| `list`, `tuple` +| `list` +| To any depth. A tuple comes back as a list. + +| `dict` +| `dict` +| To any depth, with lists and dicts inside. + +| `set` +| `set` inside the transaction, `list` after the commit +| The engine has no set type. +|=== + +A property that you declared keeps the declared type: `CREATE PROPERTY Event.day DATE` makes the property a date whatever Python type you send, and an undeclared property takes the kind of the value. Declare a property when you need a type that the Python value does not tell, such as the precision of a time. An embedded document (a property declared `EMBEDDED OF Type`) reads back as a `dict` through `to_dict()` and `to_list()`, and `em.q` reads one of its members inside a statement. + +This block stores a decimal, a tuple, a dict and some bytes, and reads them back: + +[source,python] +---- +with db.transaction(): + db.command( + "sql", + "INSERT INTO Event SET name = ?, price = ?, tags = ?, meta = ?, raw = ?", + "launch", + Decimal("19.99"), + ("a", "b"), + {"city": "Seoul", "seats": 40}, + b"\xff\x00", + ) +row = db.query("sql", "SELECT FROM Event").first() +print(repr(row.get("name"))) +print(repr(row.get("price"))) +print(repr(row.get("tags"))) +print(sorted(row.get("meta").items())) +print(repr(row.get("raw"))) +---- + +[source,text] +---- +'launch' +Decimal('19.99') +['a', 'b'] +[('city', 'Seoul'), ('seats', 40)] +[-1, 0] +---- + +=== Datetimes + +The engine stores a `DATETIME` as a UTC wall clock. The bindings follow that: a naive `datetime` is taken as the UTC time it shows, and an aware `datetime` is converted to UTC. What you read back has no time zone, so a value you wrote at 21:34 in UTC+9 reads back as 12:34. If you want an aware value, put the zone back yourself with `value.replace(tzinfo=timezone.utc)`. + +[source,python] +---- +naive = datetime(2026, 10, 1, 12, 34, 56, 789123) +aware = datetime(2026, 10, 1, 21, 34, 56, 789123, tzinfo=timezone(timedelta(hours=9))) +with db.transaction(): + for label, value in (("naive", naive), ("aware", aware)): + db.command( + "sql", + "INSERT INTO Event SET label = ?, at = ?, day = ?", + label, + value, + value.date(), + ) +rows = db.query("sql", "SELECT label, at, day FROM Event WHERE label IS NOT NULL ORDER BY label") +for row in rows: + print(row.get("label"), row.get("at"), row.get("at").tzinfo, row.get("day")) +count = db.query("sql", "SELECT count(*) AS n FROM Event WHERE at = ?", aware).first() +print("found by the aware value:", count.get("n")) +---- + +[source,text] +---- +aware 2026-10-01 12:34:56.789123 None 2026-10-01 +naive 2026-10-01 12:34:56.789123 None 2026-10-01 +found by the aware value: 2 +---- + +Both values are the same instant, so they are stored as one value, and a lookup by either finds both records. + +The declared type of the property sets the precision that is kept. `DATETIME_SECOND` keeps whole seconds, `DATETIME` keeps milliseconds, and `DATETIME_MICROS` keeps microseconds, which is all that a Python `datetime` holds. A property that you do not declare takes the kind of the value, so declare the precision you need before you load. + +[source,python] +---- +db.command("sql", "CREATE DOCUMENT TYPE Stamp") +for name, kind in (("sec", "DATETIME_SECOND"), ("ms", "DATETIME"), ("us", "DATETIME_MICROS")): + db.command("sql", f"CREATE PROPERTY Stamp.{name} {kind}") +with db.transaction(): + db.command( + "sql", + "INSERT INTO Stamp SET sec = :t, ms = :t, us = :t", + {"t": datetime(2021, 2, 3, 4, 5, 6, 123456)}, + ) +stamp = db.query("sql", "SELECT FROM Stamp").first() +for name in ("sec", "ms", "us"): + print(name, stamp.get(name)) +---- + +[source,text] +---- +sec 2021-02-03 04:05:06 +ms 2021-02-03 04:05:06.123000 +us 2021-02-03 04:05:06.123456 +---- + +=== Sets + +A `set` is a set only until the commit. This is the engine's rule: it has no set type, so it stores the elements as a list. + +[source,python] +---- +with db.transaction(): + roles = db.new_document("Event").set("roles", {"admin", "user"}).save() + print(type(roles.get("roles")).__name__) +print(type(db.lookup_by_rid(roles.get_rid()).get("roles")).__name__) +---- + +[source,text] +---- +set +list +---- + +== Rows + +`db.query(...)` gives rows. You read a row with methods. A row has no item access and no attributes: `row["name"]` raises `TypeError` and `row.name` raises `AttributeError`. `to_dict()` and `to_list()` give plain dicts, and a dict takes brackets. + +* `row.get("name")` is the value, or `None` when the row has no such property. `row.has_property("name")` tells a missing property from a value. +* `row.get_property_names()` (also the `row.property_names` property) lists the names, and `row.to_dict()` is the properties as a new dict each time. Changing that dict changes nothing in the row or in the database. +* A row that is a record (`SELECT FROM Person`) has an identity: `row.get_rid()` is its record id as text, such as `"#12:0"`, and `row.get_element()` is the record as an object (see <>). A projection row (`SELECT name FROM Person`) has neither: both return `None`. + +[source,python] +---- +db.command("sql", "CREATE VERTEX TYPE Person") +db.command("sql", "CREATE PROPERTY Person.name STRING") +db.command("sql", "CREATE EDGE TYPE Knows") +with db.transaction(): + db.command("sql", "INSERT INTO Person SET name = 'Ada', age = 36") + +row = db.query("sql", "SELECT FROM Person").first() +print(row.get("name"), row.get("nothing"), row.has_property("nothing")) +print(sorted(row.get_property_names())) +print(sorted(row.to_dict().items())) +try: + row["name"] +except TypeError: + print("no brackets on a row") +print(row.get_rid().startswith("#"), type(row.get_element()).__name__) + +projection = db.query("sql", "SELECT name FROM Person").first() +print(projection.get("name"), projection.get_rid(), projection.get_element()) +---- + +[source,text] +---- +Ada None False +['age', 'name'] +[('age', 36), ('name', 'Ada')] +no brackets on a row +True Vertex +Ada None None +---- + +[[records]] +== Records + +A record object is a `Document`, a `Vertex` or an `Edge`. A vertex and an edge are documents too, so `isinstance(record, Document)` holds for all three. You get one from `db.new_document(type)`, `db.new_vertex(type)`, `vertex.new_edge(...)`, `db.lookup_by_rid(rid)`, `db.lookup_by_key(...)`, or from a row with `row.get_element()`, `row.get_vertex()` and `row.get_edge()`. + +A record has the same readers as a row (`get`, `has_property`, `get_property_names`, `to_dict`), and `to_dict()` holds the properties only. What a row does not hold, a record has by name: `get_rid()` and `get_type_name()`. + +Creating a record takes three steps: make the object, set its properties, save it. `set()` and `save()` return the record, so they chain. `save()` is a write, so it needs a transaction, as every write does (see xref:how-to/transactions.adoc[Transactions]). The record has its record id after `save()`. + +[source,python] +---- +with db.transaction(): + grace = db.new_vertex("Person").set("name", "Grace").set("age", 45).save() + bob = db.new_vertex("Person").set("name", "Bob").save() + knows = grace.new_edge("Knows", bob, since=2020) +print(type(grace).__name__, grace.get_type_name(), grace.get_rid().startswith("#")) +print(type(knows).__name__, knows.get("since")) +print(knows.get_out().get_rid() == grace.get_rid(), knows.get_in().get_rid() == bob.get_rid()) +print([edge.get_rid() == knows.get_rid() for edge in grace.get_out_edges("Knows")]) +print(bob.get_in_edges("Knows")[0].get_out().get("name")) +---- + +[source,text] +---- +Vertex Person True +Edge 2020 +True True +[True] +Grace +---- + +Save both vertices before you call `new_edge()`. `new_edge()` creates the edge, so you do not call `save()` on it. `get_out_edges()`, `get_in_edges()` and `get_both_edges()` take edge labels to narrow the list. + +=== A record is a copy + +A record object, and a row, holds the record as it was when you read it. A later change to the stored record, through a statement or through another object, does not reach it. Read the record again to see the change. + +[source,python] +---- +rid = grace.get_rid() +record = db.lookup_by_rid(rid) +with db.transaction(): + db.command("sql", "UPDATE Person SET age = 46 WHERE name = 'Grace'") +print(record.get("age"), db.lookup_by_rid(rid).get("age")) +---- + +[source,text] +---- +45 46 +---- + +The reverse holds too. A record that you read is read-only: `set()` raises `AttributeError`, and the message names `modify()`. `modify()` returns a new, writable object for the same record, and `save()` on it writes the change. The object you read is not changed by that save, and a `set()` without a `save()` stores nothing. + +A record that you read is the state of a moment, so a save of a changed copy would overwrite the change of another writer. The bindings refuse that. `save()` raises `ArcadeDBError`, with `ConcurrentModificationException` in the message, when the stored record is no longer the one the object was read from, and it writes nothing. It is the same error `run_in_transaction` retries (see xref:how-to/concurrency.adoc#retry-loop[Concurrency, processes and crashes]). Read the record again, make the change on the fresh object, and save that. + +[source,python] +---- +try: + record.set("age", 50) +except AttributeError as error: + print("read-only:", "modify()" in str(error)) + +stale = record.modify().set("age", 99) +try: + with db.transaction(): + stale.save() +except ArcadeDBError: + print("refused: the record changed after it was read") +print(db.lookup_by_rid(rid).get("age")) + +fresh = db.lookup_by_rid(rid).modify().set("age", 47) +with db.transaction(): + fresh.save() +print(db.lookup_by_rid(rid).get("age")) +---- + +[source,text] +---- +read-only: True +refused: the record changed after it was read +46 +47 +---- + +A list or a dict that `get()` returns is a Python value, not a window on the record. Changing it in place stores nothing: call `set()` and `save()`, or run a statement. + +=== Identity + +Two reads of one record are two objects. They are not equal to each other, because a record object is equal only to itself. Compare records by `get_rid()`. + +[source,python] +---- +first = db.lookup_by_rid(rid) +second = db.lookup_by_rid(rid) +print(first is second, first == second, first.get_rid() == second.get_rid()) +---- + +[source,text] +---- +False False True +---- + +=== Lookups + +`db.lookup_by_rid(rid)` takes the text of a record id, or an RID object from a row (see xref:how-to/databases-and-queries.adoc#record-ids-in-rows[Record ids in rows]), and raises `ArcadeDBError` when no record has it. `db.lookup_by_key(type, keys, values)` finds a record through an index. It never scans: `keys` must be the properties of an existing index of that type, in the index's order, and a call without such an index raises `ArcadeDBError`. It returns the first match, or `None`. + +[source,python] +---- +try: + db.lookup_by_key("Person", ["name"], ["Grace"]) +except ArcadeDBError: + print("no index on name yet") +db.command("sql", "CREATE INDEX ON Person (name) UNIQUE") +grace = db.lookup_by_key("Person", ["name"], ["Grace"]) +print(grace.get("age"), db.lookup_by_key("Person", ["name"], ["Nobody"])) +---- + +[source,text] +---- +no index on name yet +47 None +---- + +`record.delete()` removes a record, inside a transaction. + +=== Objects or statements + +Statements are the main interface, and record objects wrap what Python control flow needs. Use a statement (`INSERT`, `UPDATE`, `DELETE`, `CREATE EDGE`) when the change is conditional, touches many records, or comes from a bulk load: it runs in the engine, takes no read of the record first, and cannot be refused as stale. Use a record object when your code reads one record, decides in Python and writes it back. For many records at once, see xref:how-to/bulk-loads.adoc[Bulk loads and commit size]. + +== The Schema API + +`db.schema` holds methods that make and drop types, properties and indexes without writing the statement. They do the same as the statements: a type made by one is a type for the other, schema changes apply at once, and they need no transaction. The statements are the main interface and cover more, so use them unless you build a schema from Python values and want the quoting of names done for you. + +[cols="2,3"] +|=== +| Method | Statement + +| `create_document_type(name)`, `create_vertex_type(name)`, `create_edge_type(name)` +| `CREATE DOCUMENT TYPE`, `CREATE VERTEX TYPE`, `CREATE EDGE TYPE` + +| `create_property(type, name, "STRING")`, with `of_type=` for a list or a map +| `CREATE PROPERTY Type.name STRING` + +| `create_index(type, [names], unique=True, index_type=IndexType.HASH)` +| `CREATE INDEX ON Type (name) UNIQUE_HASH` + +| `exists_type(name)`, `exists_index("Type[name]")` +| `SELECT FROM schema:types`, `SELECT FROM schema:indexes` + +| `get_or_create_*` and `drop_*` +| `IF NOT EXISTS` and `DROP TYPE`, `DROP PROPERTY`, `DROP INDEX` +|=== + +The property type is a string or a `PropertyType`, and the index type is a string or an `IndexType`; both enums import from `arcadedb`. An index is named `Type[property]`, and `exists_index` takes that name. The create and get methods return a Java object on the JVM package and a placeholder on the native package, so do not use what they return: check with `exists_type`, `exists_index` or a statement. + +[source,python] +---- +db.schema.create_document_type("Note") +db.schema.create_property("Note", "text", "STRING") +db.schema.create_index("Note", ["text"], unique=True) +print(db.schema.exists_type("Note"), db.schema.exists_index("Note[text]"), db.schema.exists_type("Missing")) +---- + +[source,text] +---- +True True False +---- + +[source,sql] +---- +CREATE DOCUMENT TYPE Note +CREATE PROPERTY Note.text STRING +CREATE INDEX ON Note (text) UNIQUE +---- + +Creating a type that exists raises `ArcadeDBError`, from the method and from the statement alike. `get_or_create_document_type` and `CREATE DOCUMENT TYPE Note IF NOT EXISTS` do nothing when the type is there. + +What only a statement does: the settings of a property beyond its type. `mandatory`, `notnull` and the other constraints go in the parentheses of `CREATE PROPERTY`, and the `Schema` API has no argument for them. + +[source,python] +---- +db.command("sql", "CREATE DOCUMENT TYPE Note IF NOT EXISTS") +db.schema.get_or_create_document_type("Note") +try: + db.schema.create_document_type("Note") +except ArcadeDBError: + print("Note exists") + +db.command("sql", "CREATE PROPERTY Note.title STRING (mandatory true, notnull true)") +try: + with db.transaction(): + db.command("sql", "INSERT INTO Note SET text = 'x'") +except ArcadeDBError: + print("refused: title is mandatory") +---- + +[source,text] +---- +Note exists +refused: title is mandatory +---- diff --git a/docs/modules/ROOT/pages/quickstart.adoc b/docs/modules/ROOT/pages/quickstart.adoc index 1ed5733..9e697f1 100644 --- a/docs/modules/ROOT/pages/quickstart.adoc +++ b/docs/modules/ROOT/pages/quickstart.adoc @@ -183,3 +183,4 @@ In the `{jvm-pkg-name}` wheels from 26.11.1 the server also serves the Studio we == Next steps * xref:how-to/databases-and-queries.adoc[Databases and queries]: open, drop and check databases, parameters, pagination, SQLScript and openCypher. +* xref:how-to/values-and-records.adoc[Values and records]: which Python types go in and what comes back, datetimes, record objects and the `Schema` API. diff --git a/tests/test_docs_values_page.py b/tests/test_docs_values_page.py new file mode 100644 index 0000000..4440403 --- /dev/null +++ b/tests/test_docs_values_page.py @@ -0,0 +1,62 @@ +"""Every Python block of the Antora page ``docs/modules/ROOT/pages/how-to/values-and-records.adoc`` runs, and prints what the page says. + +Same pattern as ``test_docs_vectors_page.py``: the blocks are chained on purpose, they run in the order of the page, in one +namespace, against one fresh temporary database that the namespace holds as ``db``. A block that is followed by a +``[source,text]`` block must print exactly that text. What each statement of the page claims is pinned on its own in +``test_values_and_records_behavior.py``. The test uses no Java object, so it runs on the JVM build and the native build alike. +""" + +import contextlib +import io +import re +from pathlib import Path + +PAGE = ( + Path(__file__).resolve().parents[1] + / "docs" + / "modules" + / "ROOT" + / "pages" + / "how-to" + / "values-and-records.adoc" +) + +# a Python listing, then (optionally, directly after it) the text listing with its output +BLOCK_RE = re.compile( + r"\[source,python[^\]\n]*\]\n----\n(?P.*?)\n----\n" + r"(?:\n\[source,text\]\n----\n(?P.*?)\n----\n)?", + re.DOTALL, +) + + +def _blocks(): + text = PAGE.read_text(encoding="utf-8") + found = [(m.group("code"), m.group("out")) for m in BLOCK_RE.finditer(text)] + # no Python listing may escape the extraction (a different spelling of the block header would run nowhere) + assert text.count("[source,python") == len(found) + return found + + +def test_the_page_has_its_blocks(): + blocks = _blocks() + assert len(blocks) >= 13 + assert sum(1 for _, out in blocks if out is not None) >= 12 + + +def test_every_block_runs_in_order_and_prints_what_the_page_says(temp_db): + namespace = {"db": temp_db, "__name__": "values_page"} + for number, (code, expected) in enumerate(_blocks(), start=1): + captured = io.StringIO() + compiled = compile( + code, f"values-and-records.adoc python block {number}", "exec" + ) + with contextlib.redirect_stdout(captured): + exec(compiled, namespace) # nosec B102 - the page of this repository + if expected is not None: + assert ( + captured.getvalue().strip() == expected.strip() + ), f"block {number} printed other text than the page shows:\n{code}" + else: + assert ( + captured.getvalue() == "" + ), f"block {number} prints, the page shows no output" diff --git a/tests/test_values_and_records_behavior.py b/tests/test_values_and_records_behavior.py new file mode 100644 index 0000000..99aed4c --- /dev/null +++ b/tests/test_values_and_records_behavior.py @@ -0,0 +1,537 @@ +"""What Python values and record objects do, as the how-to page ``docs/modules/ROOT/pages/how-to/values-and-records.adoc`` says it +(ArcadeData/arcadedb-python#267, rows 3, 9 and 12). + +Each statement on that page is pinned here first: how Python values go in and what comes back (scalars, ``Decimal``, ``datetime`` and +``date``, bytes, lists, maps, sets, embedded documents), what a query row and a record object give you (``get``, ``to_dict``, item and +attribute access, identity), which of them is a copy of the record as it was read, how ``modify()``, ``save()`` and a stale save +behave, and what the ``Schema`` API does next to plain SQL. The tests use SQL and the wrapper objects only, no Java object, so they +run on the JVM build and the native build alike. +""" + +import re +import struct +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal + +import pytest + +from arcadedb import ArcadeDBError, IndexType, PropertyType +from arcadedb.graph import Document, Edge, Vertex + + +def _first(db, sql, *args): + return db.query("sql", sql, *args).first() + + +# --- Python values in, Python values out ------------------------------------------------------------ + + +def test_scalars_come_back_as_the_python_type_they_went_in_as(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Scalars") + with temp_db.transaction(): + temp_db.command( + "sql", + "INSERT INTO Scalars SET s = ?, i = ?, f = ?, b = ?, n = ?", + "text", + 2**40, + 0.1, + True, + None, + ) + row = _first(temp_db, "SELECT s, i, f, b, n FROM Scalars") + assert row.get("s") == "text" and type(row.get("s")) is str + assert row.get("i") == 2**40 and type(row.get("i")) is int + assert row.get("f") == 0.1 and type(row.get("f")) is float + assert row.get("b") is True + assert row.get("n") is None + + +def test_the_declared_type_of_a_property_wins_over_the_python_type(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Declared") + temp_db.command("sql", "CREATE PROPERTY Declared.ratio DOUBLE") + temp_db.command("sql", "CREATE PROPERTY Declared.count INTEGER") + with temp_db.transaction(): + temp_db.command("sql", "INSERT INTO Declared SET ratio = ?, count = ?", 3, 7) + row = _first(temp_db, "SELECT ratio, count FROM Declared") + assert row.get("ratio") == 3.0 and type(row.get("ratio")) is float + assert row.get("count") == 7 and type(row.get("count")) is int + + +@pytest.mark.parametrize("declared", [False, True], ids=["undeclared", "declared"]) +def test_a_decimal_keeps_every_digit(temp_db, declared): + value = Decimal("12345678901234567890.123456789012345678") + temp_db.command("sql", "CREATE DOCUMENT TYPE Money") + if declared: + temp_db.command("sql", "CREATE PROPERTY Money.amount DECIMAL") + with temp_db.transaction(): + temp_db.command("sql", "INSERT INTO Money SET amount = ?", value) + got = _first(temp_db, "SELECT amount FROM Money").get("amount") + assert type(got) is Decimal and got == value + # a lookup by the same Decimal finds the record + assert ( + _first(temp_db, "SELECT count(*) AS n FROM Money WHERE amount = ?", value).get( + "n" + ) + == 1 + ) + + +NAIVE = datetime(2026, 10, 1, 12, 34, 56, 789123) +AWARE = datetime(2026, 10, 1, 21, 34, 56, 789123, tzinfo=timezone(timedelta(hours=9))) +EPOCH_MS = 1790858096789 # 2026-10-01T12:34:56.789Z + + +def test_a_naive_datetime_is_a_utc_wall_clock_and_an_aware_one_is_converted(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Event") + temp_db.command("sql", "CREATE PROPERTY Event.at DATETIME_MICROS") + with temp_db.transaction(): + for key, value in (("naive", NAIVE), ("aware", AWARE)): + temp_db.command("sql", "INSERT INTO Event SET k = ?, at = ?", key, value) + for key in ("naive", "aware"): + row = _first( + temp_db, "SELECT at, at.asLong() AS ms FROM Event WHERE k = ?", key + ) + # both are 12:34:56.789123 UTC, and what comes back has no time zone + assert row.get("at") == NAIVE and row.get("at").tzinfo is None, key + assert row.get("ms") == EPOCH_MS, key + # a lookup by either value finds both records + for value in (NAIVE, AWARE): + assert ( + _first(temp_db, "SELECT count(*) AS n FROM Event WHERE at = ?", value).get( + "n" + ) + == 2 + ) + + +def test_the_precision_of_the_declared_type_decides_what_a_datetime_keeps(temp_db): + value = datetime(2021, 2, 3, 4, 5, 6, 123456) + temp_db.command("sql", "CREATE DOCUMENT TYPE Stamp") + for name, declared in ( + ("sec", "DATETIME_SECOND"), + ("ms", "DATETIME"), + ("us", "DATETIME_MICROS"), + ): + temp_db.command("sql", f"CREATE PROPERTY Stamp.{name} {declared}") + with temp_db.transaction(): + temp_db.command( + "sql", "INSERT INTO Stamp SET sec = :v, ms = :v, us = :v", {"v": value} + ) + row = _first(temp_db, "SELECT sec, ms, us FROM Stamp") + assert row.get("sec") == datetime(2021, 2, 3, 4, 5, 6) + assert row.get("ms") == datetime(2021, 2, 3, 4, 5, 6, 123000) + assert row.get("us") == value + + +def test_a_date_is_a_date_and_an_undeclared_value_keeps_its_kind(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Day") + temp_db.command("sql", "CREATE PROPERTY Day.declared DATE") + with temp_db.transaction(): + temp_db.command( + "sql", + "INSERT INTO Day SET declared = :d, loose = :d, moment = :t", + {"d": date(2026, 10, 1), "t": NAIVE}, + ) + row = _first(temp_db, "SELECT declared, loose, moment FROM Day") + assert type(row.get("declared")) is date and row.get("declared") == date( + 2026, 10, 1 + ) + assert type(row.get("loose")) is date and row.get("loose") == date(2026, 10, 1) + # a datetime is not a date: it stays a datetime + assert type(row.get("moment")) is datetime + + +def test_bytes_come_back_as_a_list_of_signed_ints(temp_db): + payload = b"\xff\x00\xfe\x80Hello" + temp_db.command("sql", "CREATE DOCUMENT TYPE Blob") + with temp_db.transaction(): + temp_db.command("sql", "INSERT INTO Blob SET data = ?", payload) + got = _first(temp_db, "SELECT data FROM Blob").get("data") + assert got == list(struct.unpack(f"{len(payload)}b", payload)) + assert bytes(b & 0xFF for b in got) == payload + + +def test_lists_and_maps_are_lists_and_dicts_to_any_depth(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Shapes") + nested = {"tags": ["a", "b"], "inner": {"n": 1, "ok": True}, "none": None} + with temp_db.transaction(): + temp_db.command( + "sql", + "INSERT INTO Shapes SET mixed = ?, tup = ?, nested = ?", + [1, "a", None, 2.5, True], + (1, 2, 3), + nested, + ) + row = _first(temp_db, "SELECT mixed, tup, nested FROM Shapes") + assert ( + row.get("mixed") == [1, "a", None, 2.5, True] and type(row.get("mixed")) is list + ) + # a tuple goes in as a list and comes back as one + assert row.get("tup") == [1, 2, 3] and type(row.get("tup")) is list + assert row.get("nested") == nested and type(row.get("nested")) is dict + + +def test_a_set_is_a_set_until_the_commit_and_a_list_after_it(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Bag") + with temp_db.transaction(): + bag = ( + temp_db.new_document("Bag").set("roles", {"admin", "user", "admin"}).save() + ) + assert bag.get("roles") == {"admin", "user"} + for got in ( + temp_db.lookup_by_rid(bag.get_rid()).get("roles"), + _first(temp_db, "SELECT roles FROM Bag").get("roles"), + ): + assert type(got) is list and sorted(got) == ["admin", "user"] + + +def test_an_embedded_document_reads_back_as_a_dict_through_to_dict(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Emb") + temp_db.command("sql", "CREATE DOCUMENT TYPE Holder") + temp_db.command("sql", "CREATE PROPERTY Holder.em EMBEDDED OF Emb") + with temp_db.transaction(): + temp_db.command("sql", "INSERT INTO Holder SET em = {'@type': 'Emb', 'q': 1}") + row = _first(temp_db, "SELECT FROM Holder") + for value in ( + row.to_dict()["em"], + temp_db.query("sql", "SELECT FROM Holder").to_list()[0]["em"], + ): + assert isinstance(value, dict) and value["q"] == 1 + assert _first(temp_db, "SELECT em.q AS q FROM Holder").get("q") == 1 + + +def test_get_to_dict_and_to_list_give_the_same_values(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Same") + temp_db.command("sql", "CREATE PROPERTY Same.d DATE") + temp_db.command("sql", "CREATE PROPERTY Same.t DATETIME_MICROS") + with temp_db.transaction(): + temp_db.command( + "sql", + "INSERT INTO Same SET s = 'x', i = 1, f = 2.5, n = :n, d = :d, t = :t, li = [1, 2], ma = {'k': 'v'}", + {"n": Decimal("1.50"), "d": date(2026, 10, 1), "t": NAIVE}, + ) + row = _first(temp_db, "SELECT s, i, f, n, d, t, li, ma FROM Same") + as_dict = row.to_dict() + as_listed = temp_db.query( + "sql", "SELECT s, i, f, n, d, t, li, ma FROM Same" + ).to_list()[0] + assert set(as_dict) == set(as_listed) == {"s", "i", "f", "n", "d", "t", "li", "ma"} + for name in as_dict: + values = (row.get(name), as_dict[name], as_listed[name]) + assert len({type(v) for v in values}) == 1, name + assert values[0] == values[1] == values[2], name + + +# --- Query rows ------------------------------------------------------------------------------------- + + +@pytest.fixture +def people(temp_db): + temp_db.command("sql", "CREATE VERTEX TYPE Person") + temp_db.command("sql", "CREATE EDGE TYPE Knows") + with temp_db.transaction(): + temp_db.command("sql", "INSERT INTO Person SET name = 'Ada', age = 36") + return temp_db + + +def test_a_row_is_read_with_get_not_with_brackets_or_attributes(people): + row = _first(people, "SELECT FROM Person") + assert row.get("name") == "Ada" + with pytest.raises(TypeError): + row["name"] + with pytest.raises(AttributeError): + row.name + # to_list() and to_dict() are plain dicts, which take brackets + listed = people.query("sql", "SELECT FROM Person").to_list() + assert type(listed[0]) is dict and listed[0]["name"] == "Ada" + assert type(row.to_dict()) is dict and row.to_dict()["name"] == "Ada" + + +def test_a_missing_property_is_none_and_has_property_tells_it_from_a_value(people): + row = _first(people, "SELECT FROM Person") + assert row.get("nothing") is None + assert row.has_property("nothing") is False + assert row.has_property("name") is True + assert sorted(row.get_property_names()) == ["age", "name"] + assert sorted(row.property_names) == ["age", "name"] + assert row.to_dict() == {"name": "Ada", "age": 36} + + +def test_to_dict_is_a_new_dict_each_time(people): + row = _first(people, "SELECT FROM Person") + changed = row.to_dict() + changed["name"] = "someone else" + assert row.get("name") == "Ada" + assert row.to_dict()["name"] == "Ada" + + +def test_a_record_row_has_a_rid_and_an_element_and_a_projection_row_has_neither(people): + record_row = _first(people, "SELECT FROM Person") + assert re.fullmatch(r"#\d+:\d+", record_row.get_rid()) + assert isinstance(record_row.get_element(), Vertex) + assert isinstance(record_row.get_vertex(), Vertex) + assert record_row.get_element().get_rid() == record_row.get_rid() + + projection = _first(people, "SELECT name FROM Person") + assert projection.get_rid() is None + assert projection.get_element() is None + assert projection.get("name") == "Ada" + + +def test_the_rows_of_a_document_type_wrap_as_documents_and_of_an_edge_type_as_edges( + people, +): + people.command("sql", "CREATE DOCUMENT TYPE Note") + with people.transaction(): + people.command("sql", "INSERT INTO Note SET text = 'hi'") + a = people.new_vertex("Person").set("name", "Bob").save() + b = people.new_vertex("Person").set("name", "Cy").save() + a.new_edge("Knows", b, since=2020) + note = _first(people, "SELECT FROM Note").get_element() + assert isinstance(note, Document) + assert not isinstance(note, (Vertex, Edge)) + edge = _first(people, "SELECT FROM Knows").get_element() + assert isinstance(edge, Edge) and edge.get("since") == 2020 + # a Vertex and an Edge are Documents too + assert isinstance(edge, Document) and isinstance(a, Document) + + +# --- Record objects --------------------------------------------------------------------------------- + + +def test_new_document_set_and_save_chain_and_return_the_same_object(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Task") + with temp_db.transaction(): + task = temp_db.new_document("Task") + assert isinstance(task, Document) + assert task.set("title", "write docs") is task + assert task.set("priority", 2).save() is task + assert re.fullmatch(r"#\d+:\d+", task.get_rid()) + assert task.get_type_name() == "Task" + # the record is readable by a statement in the same transaction + assert _first(temp_db, "SELECT count(*) AS n FROM Task").get("n") == 1 + assert temp_db.lookup_by_rid(task.get_rid()).to_dict() == { + "title": "write docs", + "priority": 2, + } + + +def test_save_is_a_write_and_needs_a_transaction(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Task") + task = temp_db.new_document("Task").set("title", "x") + with pytest.raises(ArcadeDBError, match="Transaction not begun"): + task.save() + assert _first(temp_db, "SELECT count(*) AS n FROM Task").get("n") == 0 + + +def test_a_record_that_was_read_is_immutable_until_modify(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Task") + with temp_db.transaction(): + rid = temp_db.new_document("Task").set("n", 1).save().get_rid() + record = temp_db.lookup_by_rid(rid) + with pytest.raises(AttributeError, match="immutable"): + record.set("n", 2) + mutable = record.modify() + assert mutable is not record + assert mutable.set("n", 2) is mutable + + +def test_modify_and_save_change_the_stored_record_and_not_the_object_that_was_read( + temp_db, +): + temp_db.command("sql", "CREATE DOCUMENT TYPE Task") + with temp_db.transaction(): + rid = temp_db.new_document("Task").set("n", 1).save().get_rid() + record = temp_db.lookup_by_rid(rid) + assert record.get("n") == 1 + + with temp_db.transaction(): + mutable = record.modify().set("n", 2) + mutable.save() + # a statement in the same transaction sees the change; the object that was read does not + assert _first(temp_db, "SELECT n FROM Task").get("n") == 2 + assert record.get("n") == 1 + assert record.get("n") == 1 + assert temp_db.lookup_by_rid(rid).get("n") == 2 + + +def test_set_without_save_stores_nothing(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Task") + with temp_db.transaction(): + rid = temp_db.new_document("Task").set("n", 1).save().get_rid() + with temp_db.transaction(): + temp_db.lookup_by_rid(rid).modify().set("n", 99) + assert _first(temp_db, "SELECT n FROM Task").get("n") == 1 + + +def test_a_record_and_a_row_are_the_record_as_it_was_read_a_statement_does_not_update_them( + temp_db, +): + temp_db.command("sql", "CREATE DOCUMENT TYPE Task") + with temp_db.transaction(): + rid = temp_db.new_document("Task").set("n", 1).save().get_rid() + record = temp_db.lookup_by_rid(rid) + row = _first(temp_db, "SELECT FROM Task") + assert record.get("n") == 1 and row.get("n") == 1 + + with temp_db.transaction(): + temp_db.command("sql", "UPDATE Task SET n = 2") + assert _first(temp_db, "SELECT n FROM Task").get("n") == 2 + assert record.get("n") == 1 and row.get("n") == 1 + assert record.to_dict() == {"n": 1} and row.to_dict() == {"n": 1} + assert temp_db.lookup_by_rid(rid).get("n") == 2 + + +def test_saving_a_record_that_changed_after_it_was_read_is_refused(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Task") + with temp_db.transaction(): + rid = temp_db.new_document("Task").set("n", 1).set("m", 1).save().get_rid() + mutable = temp_db.lookup_by_rid(rid).modify() + with temp_db.transaction(): + temp_db.command("sql", "UPDATE Task SET m = 2") + mutable.set("n", 10) + with pytest.raises(ArcadeDBError) as refused: + with temp_db.transaction(): + mutable.save() + assert "oncurrent" in str(refused.value) or rid in str(refused.value) + # nothing of the refused save is stored, and the other change is kept + assert _first(temp_db, "SELECT n, m FROM Task").to_dict() == {"n": 1, "m": 2} + + # a fresh read sees the new state and saves + fresh = temp_db.lookup_by_rid(rid).modify().set("n", 10) + with temp_db.transaction(): + fresh.save() + assert _first(temp_db, "SELECT n, m FROM Task").to_dict() == {"n": 10, "m": 2} + + +def test_two_reads_of_one_record_are_two_objects_with_one_rid(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Task") + with temp_db.transaction(): + rid = temp_db.new_document("Task").set("n", 1).save().get_rid() + first = temp_db.lookup_by_rid(rid) + second = temp_db.lookup_by_rid(rid) + third = _first(temp_db, "SELECT FROM Task").get_element() + assert first is not second + assert first != second # no __eq__: objects are equal only to themselves + assert first.get_rid() == second.get_rid() == third.get_rid() == rid + + +def test_lookup_by_rid_raises_for_a_record_that_is_not_there_and_delete_removes_one( + temp_db, +): + temp_db.command("sql", "CREATE DOCUMENT TYPE Task") + with temp_db.transaction(): + rid = temp_db.new_document("Task").set("n", 1).save().get_rid() + with temp_db.transaction(): + temp_db.lookup_by_rid(rid).delete() + assert _first(temp_db, "SELECT count(*) AS n FROM Task").get("n") == 0 + with pytest.raises(ArcadeDBError): + temp_db.lookup_by_rid(rid) + + +def test_lookup_by_key_needs_an_index_and_returns_a_record_or_none(temp_db): + temp_db.command("sql", "CREATE VERTEX TYPE User") + temp_db.command("sql", "CREATE PROPERTY User.email STRING") + with temp_db.transaction(): + temp_db.command( + "sql", "INSERT INTO User SET email = 'ada@example.com', name = 'Ada'" + ) + # no index on email yet: lookup_by_key never scans + with pytest.raises(ArcadeDBError): + temp_db.lookup_by_key("User", ["email"], ["ada@example.com"]) + temp_db.command("sql", "CREATE INDEX ON User (email) UNIQUE") + found = temp_db.lookup_by_key("User", ["email"], ["ada@example.com"]) + assert isinstance(found, Vertex) and found.get("name") == "Ada" + assert temp_db.lookup_by_key("User", ["email"], ["nobody@example.com"]) is None + + +def test_vertices_and_edges_are_created_saved_first_and_walked_by_method(people): + with people.transaction(): + ada = people.new_vertex("Person").set("name", "Ada2").save() + bob = people.new_vertex("Person").set("name", "Bob").save() + edge = ada.new_edge("Knows", bob, since=2020) + assert isinstance(edge, Edge) + assert edge.get("since") == 2020 + assert edge.get_out().get_rid() == ada.get_rid() + assert edge.get_in().get_rid() == bob.get_rid() + assert [e.get_rid() for e in ada.get_out_edges("Knows")] == [edge.get_rid()] + assert [e.get_rid() for e in bob.get_in_edges("Knows")] == [edge.get_rid()] + assert ada.get_in_edges() == [] + stored = _first(people, "SELECT FROM Knows").get_edge() + assert stored.get("since") == 2020 + + +# --- The Schema API next to SQL --------------------------------------------------------------------- + + +def test_the_schema_api_and_sql_make_the_same_schema_and_need_no_transaction(temp_db): + schema = temp_db.schema + schema.create_vertex_type("Person") + schema.create_property("Person", "email", "STRING") + schema.create_property("Person", "age", PropertyType.INTEGER) + schema.create_index("Person", ["email"], unique=True, index_type=IndexType.HASH) + temp_db.command("sql", "CREATE DOCUMENT TYPE Note") + temp_db.command("sql", "CREATE PROPERTY Note.text STRING") + temp_db.command("sql", "CREATE INDEX ON Note (text) UNIQUE") + assert temp_db.is_transaction_active() is False + + for name in ("Person", "Note"): + assert schema.exists_type(name) is True + assert schema.exists_type("Nothing") is False + assert schema.exists_index("Person[email]") is True + assert schema.exists_index("Note[text]") is True + + # a type made by the API takes SQL, and the index it made is enforced + with temp_db.transaction(): + temp_db.command("sql", "INSERT INTO Person SET email = 'a@x.org', age = 3") + with pytest.raises(ArcadeDBError, match="DuplicatedKeyException"): + with temp_db.transaction(): + temp_db.command("sql", "INSERT INTO Person SET email = 'a@x.org', age = 4") + row = _first(temp_db, "SELECT age FROM Person") + assert row.get("age") == 3 and type(row.get("age")) is int + + +def test_creating_what_exists_fails_unless_it_is_get_or_create_or_if_not_exists( + temp_db, +): + schema = temp_db.schema + schema.create_vertex_type("Person") + with pytest.raises(ArcadeDBError): + schema.create_vertex_type("Person") + with pytest.raises(ArcadeDBError): + temp_db.command("sql", "CREATE VERTEX TYPE Person") + schema.get_or_create_vertex_type("Person") + temp_db.command("sql", "CREATE VERTEX TYPE Person IF NOT EXISTS") + schema.create_property("Person", "name", "STRING") + schema.get_or_create_property("Person", "name", "STRING") + temp_db.command("sql", "CREATE PROPERTY Person.name IF NOT EXISTS STRING") + assert schema.exists_type("Person") is True + + +def test_constraints_and_other_property_settings_are_sql(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Account") + temp_db.command( + "sql", "CREATE PROPERTY Account.name STRING (mandatory true, notnull true)" + ) + with temp_db.transaction(): + temp_db.command("sql", "INSERT INTO Account SET name = 'ok'") + with pytest.raises(ArcadeDBError): + with temp_db.transaction(): + temp_db.command("sql", "INSERT INTO Account SET other = 1") + assert _first(temp_db, "SELECT count(*) AS n FROM Account").get("n") == 1 + + +def test_drop_type_and_drop_index_by_the_api_and_by_sql(temp_db): + schema = temp_db.schema + schema.create_document_type("A") + schema.create_property("A", "k", "STRING") + schema.create_index("A", ["k"], unique=False) + assert schema.exists_index("A[k]") is True + schema.drop_index("A[k]") + assert schema.exists_index("A[k]") is False + schema.drop_type("A") + assert schema.exists_type("A") is False + + temp_db.command("sql", "CREATE DOCUMENT TYPE B") + temp_db.command("sql", "DROP TYPE B") + assert schema.exists_type("B") is False From c9d9992eef2303026c29b6366f36898be8cf886c Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Fri, 6 Nov 2026 12:44:47 +0900 Subject: [PATCH 2/2] Docs: embedded documents differ per build, apply the editorial review, pin the unbacked sentences (refs #267) CI run 42155789721 failed one test: on the JVM build to_dict() and to_list() give an embedded document as the engine's own Java object (ImmutableEmbeddedDocument), not a dict. The native codec writes it as a JSON object with its @type (NativeCodec.appendDocument), so it is a dict there. The test now states that per backend, and the page says it once in the types section and in the short version; a dotted path in the statement (SELECT em.q) reads a member as a plain value on both and is pinned. The editorial patch is applied. Where it left a sentence with no test, a test now pins it (bytearray as a list of signed ints, get_both_edges with a label, a RID object from a row passed to lookup_by_rid, a composite index for lookup_by_key, plain DROP TYPE refusing a graph type with records while UNSAFE and drop_type drop it); two sentences that no test pins are cut: that a list or dict from get() is not a live view (the native build returns the held object), and that lookup_by_key goes in the index's order and returns the first match. Co-Authored-By: Claude Sonnet 5.5 --- .../ROOT/pages/how-to/values-and-records.adoc | 52 +++++++++------- tests/test_values_and_records_behavior.py | 61 +++++++++++++++++-- 2 files changed, 87 insertions(+), 26 deletions(-) diff --git a/docs/modules/ROOT/pages/how-to/values-and-records.adoc b/docs/modules/ROOT/pages/how-to/values-and-records.adoc index 17b2e88..b4ea56f 100644 --- a/docs/modules/ROOT/pages/how-to/values-and-records.adoc +++ b/docs/modules/ROOT/pages/how-to/values-and-records.adoc @@ -5,13 +5,18 @@ This page covers what the Python bindings add to the statements: how a Python value becomes a stored value and back, what the objects you get from a query or a lookup are, and where the `Schema` API fits. The types, the date formats and the schema rules of the engine belong to the main manual: {arcadedb-docs-url}/reference/datatypes[Data types], {arcadedb-docs-url}/reference/managing-dates[Managing dates] and {arcadedb-docs-url}/concepts/schema[Schema]. -The statements stay SQL or openCypher, as on xref:how-to/databases-and-queries.adoc[Databases and queries]. Record objects and the `Schema` API are there for the cases where Python code, not a statement, drives the work. +The short version: -The blocks on this page run one after the other against one empty database, and a test in the repository runs them that way (`tests/test_docs_values_page.py`). Each block that prints something is followed by its output. Every block assumes that `db` is an open database. Both packages give the same output. +* A Python value comes back as the same Python type, with these exceptions: a `datetime` has no time zone, `bytes` is a list of signed integers, a tuple is a list, a `set` is a list after the commit, and an embedded document is a Java object on `arcadedb-jvm` (read its members with a dotted path in the statement). +* You read a query row with `get()`, not with brackets. +* A record that you read is a copy and is read-only. To change it, call `modify()`, `set()` and `save()`. +* Statements stay the main interface, as on xref:how-to/databases-and-queries.adoc[Databases and queries]. Use a record object or the `Schema` API only when Python code, not a statement, drives the work. + +The blocks on this page run one after the other against one empty database, and a test in the repository runs them that way (`tests/test_docs_values_page.py`). Each block that prints something is followed by its output. Every block assumes that `db` is an open database. The output is the same on `arcadedb` and `arcadedb-jvm`. [source,python] ---- -from datetime import date, datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone from decimal import Decimal from arcadedb import ArcadeDBError @@ -70,7 +75,7 @@ A value you bind to a parameter, pass to `set()` or give to `new_edge()` is conv | The engine has no set type. |=== -A property that you declared keeps the declared type: `CREATE PROPERTY Event.day DATE` makes the property a date whatever Python type you send, and an undeclared property takes the kind of the value. Declare a property when you need a type that the Python value does not tell, such as the precision of a time. An embedded document (a property declared `EMBEDDED OF Type`) reads back as a `dict` through `to_dict()` and `to_list()`, and `em.q` reads one of its members inside a statement. +A property that you declared keeps the declared type, and an undeclared property takes the kind of the value. Declare a property when you need a type that the Python value does not tell, such as the precision of a time. An embedded document (a property declared `EMBEDDED OF Type`) is the one value that differs between the packages when you read it whole: `arcadedb` gives a `dict` that holds its members and a `@type` member with the type name, and `arcadedb-jvm` gives the engine's own Java object, not a `dict`. That is what `to_dict()` and `to_list()` return. To read a member as a plain value on both packages, name it in the statement with a dotted path: for a property `em` of type `Holder`, `SELECT em.q AS q FROM Holder` returns the member `q` as a plain value. This block stores a decimal, a tuple, a dict and some bytes, and reads them back: @@ -136,7 +141,7 @@ found by the aware value: 2 Both values are the same instant, so they are stored as one value, and a lookup by either finds both records. -The declared type of the property sets the precision that is kept. `DATETIME_SECOND` keeps whole seconds, `DATETIME` keeps milliseconds, and `DATETIME_MICROS` keeps microseconds, which is all that a Python `datetime` holds. A property that you do not declare takes the kind of the value, so declare the precision you need before you load. +The declared type of the property sets the precision that is kept. `DATETIME_SECOND` keeps whole seconds, `DATETIME` keeps milliseconds, and `DATETIME_MICROS` keeps microseconds, which is all that a Python `datetime` holds. Declare the precision you need before you load. [source,python] ---- @@ -179,11 +184,11 @@ set list ---- -== Rows +== Read a row -`db.query(...)` gives rows. You read a row with methods. A row has no item access and no attributes: `row["name"]` raises `TypeError` and `row.name` raises `AttributeError`. `to_dict()` and `to_list()` give plain dicts, and a dict takes brackets. +`db.query(...)` gives rows, and you read a row with methods. `row["name"]` raises `TypeError` and `row.name` raises `AttributeError`. `to_dict()` and `to_list()` give plain dicts, which take brackets. -* `row.get("name")` is the value, or `None` when the row has no such property. `row.has_property("name")` tells a missing property from a value. +* `row.get("name")` is the value, or `None` when the row has no such property. `row.has_property("name")` is `False` when the row has no such property. * `row.get_property_names()` (also the `row.property_names` property) lists the names, and `row.to_dict()` is the properties as a new dict each time. Changing that dict changes nothing in the row or in the database. * A row that is a record (`SELECT FROM Person`) has an identity: `row.get_rid()` is its record id as text, such as `"#12:0"`, and `row.get_element()` is the record as an object (see <>). A projection row (`SELECT name FROM Person`) has neither: both return `None`. @@ -220,13 +225,13 @@ Ada None None ---- [[records]] -== Records +== Work with records A record object is a `Document`, a `Vertex` or an `Edge`. A vertex and an edge are documents too, so `isinstance(record, Document)` holds for all three. You get one from `db.new_document(type)`, `db.new_vertex(type)`, `vertex.new_edge(...)`, `db.lookup_by_rid(rid)`, `db.lookup_by_key(...)`, or from a row with `row.get_element()`, `row.get_vertex()` and `row.get_edge()`. -A record has the same readers as a row (`get`, `has_property`, `get_property_names`, `to_dict`), and `to_dict()` holds the properties only. What a row does not hold, a record has by name: `get_rid()` and `get_type_name()`. +A record has the same readers as a row (`get`, `has_property`, `get_property_names`, `to_dict`), and `to_dict()` holds the properties only. A record also has `get_type_name()`, and `get_rid()` gives its record id. -Creating a record takes three steps: make the object, set its properties, save it. `set()` and `save()` return the record, so they chain. `save()` is a write, so it needs a transaction, as every write does (see xref:how-to/transactions.adoc[Transactions]). The record has its record id after `save()`. +Creating a record takes three steps: make the object, set its properties, save it. `set()` and `save()` return the record, so they chain. `save()` is a write, so it needs a transaction, as every write does (see xref:how-to/transactions.adoc[Transactions]). The record has its record id after `save()`. `record.delete()` removes a record, also inside a transaction. [source,python] ---- @@ -270,9 +275,9 @@ print(record.get("age"), db.lookup_by_rid(rid).get("age")) 45 46 ---- -The reverse holds too. A record that you read is read-only: `set()` raises `AttributeError`, and the message names `modify()`. `modify()` returns a new, writable object for the same record, and `save()` on it writes the change. The object you read is not changed by that save, and a `set()` without a `save()` stores nothing. +A record that you read is also read-only: `set()` raises `AttributeError`, and the message names `modify()`. `modify()` returns a new, writable object for the same record, and `save()` on it writes the change. The object you read is not changed by that save, and a `set()` without a `save()` stores nothing. -A record that you read is the state of a moment, so a save of a changed copy would overwrite the change of another writer. The bindings refuse that. `save()` raises `ArcadeDBError`, with `ConcurrentModificationException` in the message, when the stored record is no longer the one the object was read from, and it writes nothing. It is the same error `run_in_transaction` retries (see xref:how-to/concurrency.adoc#retry-loop[Concurrency, processes and crashes]). Read the record again, make the change on the fresh object, and save that. +A save of a copy that is out of date would overwrite the change of another writer, so the bindings refuse it. `save()` raises `ArcadeDBError`, with `ConcurrentModificationException` in the message, when the stored record is no longer the one the object was read from, and it writes nothing. It is the same error that `run_in_transaction` retries (see xref:how-to/concurrency.adoc#retry-loop[Concurrency, processes and crashes]). Read the record again, make the change on the fresh object, and save that. [source,python] ---- @@ -303,7 +308,7 @@ refused: the record changed after it was read 47 ---- -A list or a dict that `get()` returns is a Python value, not a window on the record. Changing it in place stores nothing: call `set()` and `save()`, or run a statement. +To change a record, call `modify()`, `set()` and `save()`, or run a statement. === Identity @@ -323,7 +328,7 @@ False False True === Lookups -`db.lookup_by_rid(rid)` takes the text of a record id, or an RID object from a row (see xref:how-to/databases-and-queries.adoc#record-ids-in-rows[Record ids in rows]), and raises `ArcadeDBError` when no record has it. `db.lookup_by_key(type, keys, values)` finds a record through an index. It never scans: `keys` must be the properties of an existing index of that type, in the index's order, and a call without such an index raises `ArcadeDBError`. It returns the first match, or `None`. +`db.lookup_by_rid(rid)` takes the text of a record id, or an RID object from a row (see xref:how-to/databases-and-queries.adoc#record-ids-in-rows[Record ids in rows]), and raises `ArcadeDBError` when no record has it. `db.lookup_by_key(type, keys, values)` finds a record through an index. It never scans: `keys` must be the properties of an existing index of that type, and a call without such an index raises `ArcadeDBError`. It returns a matching record, or `None`. [source,python] ---- @@ -342,9 +347,7 @@ no index on name yet 47 None ---- -`record.delete()` removes a record, inside a transaction. - -=== Objects or statements +=== Records or statements Statements are the main interface, and record objects wrap what Python control flow needs. Use a statement (`INSERT`, `UPDATE`, `DELETE`, `CREATE EDGE`) when the change is conditional, touches many records, or comes from a bulk load: it runs in the engine, takes no read of the record first, and cannot be refused as stale. Use a record object when your code reads one record, decides in Python and writes it back. For many records at once, see xref:how-to/bulk-loads.adoc[Bulk loads and commit size]. @@ -368,11 +371,14 @@ Statements are the main interface, and record objects wrap what Python control f | `exists_type(name)`, `exists_index("Type[name]")` | `SELECT FROM schema:types`, `SELECT FROM schema:indexes` -| `get_or_create_*` and `drop_*` -| `IF NOT EXISTS` and `DROP TYPE`, `DROP PROPERTY`, `DROP INDEX` +| `get_or_create_*` +| `IF NOT EXISTS` + +| `drop_type(name)`, `drop_property(type, name)`, `drop_index(name)` +| `DROP TYPE Type UNSAFE`, `DROP PROPERTY Type.name`, `DROP INDEX name` |=== -The property type is a string or a `PropertyType`, and the index type is a string or an `IndexType`; both enums import from `arcadedb`. An index is named `Type[property]`, and `exists_index` takes that name. The create and get methods return a Java object on the JVM package and a placeholder on the native package, so do not use what they return: check with `exists_type`, `exists_index` or a statement. +The property type is a string or a `PropertyType`, and the index type is a string or an `IndexType`; both enums import from `arcadedb`. An index is named `Type[property]`, and `exists_index` takes that name. The create and get methods return a Java object on the JVM package and a placeholder on the native package, so do not use what they return: check with `exists_type`, `exists_index` or a statement. `drop_type` drops a type together with its records. The plain statement `DROP TYPE` refuses a vertex or edge type that still holds records, and `DROP TYPE ... UNSAFE` does what `drop_type` does. [source,python] ---- @@ -387,6 +393,8 @@ print(db.schema.exists_type("Note"), db.schema.exists_index("Note[text]"), db.sc True True False ---- +The same schema as statements: + [source,sql] ---- CREATE DOCUMENT TYPE Note @@ -396,7 +404,7 @@ CREATE INDEX ON Note (text) UNIQUE Creating a type that exists raises `ArcadeDBError`, from the method and from the statement alike. `get_or_create_document_type` and `CREATE DOCUMENT TYPE Note IF NOT EXISTS` do nothing when the type is there. -What only a statement does: the settings of a property beyond its type. `mandatory`, `notnull` and the other constraints go in the parentheses of `CREATE PROPERTY`, and the `Schema` API has no argument for them. +Only a statement sets the constraints of a property. `mandatory`, `notnull` and the others go in the parentheses of `CREATE PROPERTY`, and the `Schema` API has no argument for them. [source,python] ---- diff --git a/tests/test_values_and_records_behavior.py b/tests/test_values_and_records_behavior.py index 99aed4c..282f709 100644 --- a/tests/test_values_and_records_behavior.py +++ b/tests/test_values_and_records_behavior.py @@ -15,6 +15,7 @@ import pytest +import arcadedb from arcadedb import ArcadeDBError, IndexType, PropertyType from arcadedb.graph import Document, Edge, Vertex @@ -141,11 +142,12 @@ def test_a_date_is_a_date_and_an_undeclared_value_keeps_its_kind(temp_db): assert type(row.get("moment")) is datetime -def test_bytes_come_back_as_a_list_of_signed_ints(temp_db): +@pytest.mark.parametrize("kind", [bytes, bytearray]) +def test_bytes_come_back_as_a_list_of_signed_ints(temp_db, kind): payload = b"\xff\x00\xfe\x80Hello" temp_db.command("sql", "CREATE DOCUMENT TYPE Blob") with temp_db.transaction(): - temp_db.command("sql", "INSERT INTO Blob SET data = ?", payload) + temp_db.command("sql", "INSERT INTO Blob SET data = ?", kind(payload)) got = _first(temp_db, "SELECT data FROM Blob").get("data") assert got == list(struct.unpack(f"{len(payload)}b", payload)) assert bytes(b & 0xFF for b in got) == payload @@ -185,7 +187,7 @@ def test_a_set_is_a_set_until_the_commit_and_a_list_after_it(temp_db): assert type(got) is list and sorted(got) == ["admin", "user"] -def test_an_embedded_document_reads_back_as_a_dict_through_to_dict(temp_db): +def test_an_embedded_document_is_a_dict_on_native_and_a_java_object_on_jvm(temp_db): temp_db.command("sql", "CREATE DOCUMENT TYPE Emb") temp_db.command("sql", "CREATE DOCUMENT TYPE Holder") temp_db.command("sql", "CREATE PROPERTY Holder.em EMBEDDED OF Emb") @@ -196,8 +198,16 @@ def test_an_embedded_document_reads_back_as_a_dict_through_to_dict(temp_db): row.to_dict()["em"], temp_db.query("sql", "SELECT FROM Holder").to_list()[0]["em"], ): - assert isinstance(value, dict) and value["q"] == 1 + if arcadedb.__backend__ == "native": + # the native codec writes a nested document as a JSON object with its type name (NativeCodec.appendDocument) + assert isinstance(value, dict) + assert value["q"] == 1 and value["@type"] == "Emb" + else: + # the JVM build hands over the engine's own object, which is not a dict + assert not isinstance(value, dict) + # a path in the statement reads a member as a plain value on both builds assert _first(temp_db, "SELECT em.q AS q FROM Holder").get("q") == 1 + assert temp_db.query("sql", "SELECT em.q AS q FROM Holder").to_list() == [{"q": 1}] def test_get_to_dict_and_to_list_give_the_same_values(temp_db): @@ -457,6 +467,9 @@ def test_vertices_and_edges_are_created_saved_first_and_walked_by_method(people) assert [e.get_rid() for e in ada.get_out_edges("Knows")] == [edge.get_rid()] assert [e.get_rid() for e in bob.get_in_edges("Knows")] == [edge.get_rid()] assert ada.get_in_edges() == [] + assert [e.get_rid() for e in ada.get_both_edges("Knows")] == [edge.get_rid()] + assert [e.get_rid() for e in bob.get_both_edges("Knows")] == [edge.get_rid()] + assert ada.get_both_edges("Nothing") == [] stored = _first(people, "SELECT FROM Knows").get_edge() assert stored.get("since") == 2020 @@ -535,3 +548,43 @@ def test_drop_type_and_drop_index_by_the_api_and_by_sql(temp_db): temp_db.command("sql", "CREATE DOCUMENT TYPE B") temp_db.command("sql", "DROP TYPE B") assert schema.exists_type("B") is False + + +def test_lookup_by_rid_takes_the_text_or_the_rid_object_of_a_row(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Task") + with temp_db.transaction(): + rid = temp_db.new_document("Task").set("n", 1).save().get_rid() + from_row = _first(temp_db, "SELECT @rid AS r FROM Task").get("r") + assert str(from_row) == rid + assert temp_db.lookup_by_rid(from_row).get("n") == 1 + assert temp_db.lookup_by_rid(rid).get("n") == 1 + + +def test_lookup_by_key_takes_the_properties_of_a_composite_index(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE Pair") + temp_db.command("sql", "CREATE PROPERTY Pair.a INTEGER") + temp_db.command("sql", "CREATE PROPERTY Pair.b INTEGER") + temp_db.command("sql", "CREATE INDEX ON Pair (a, b) UNIQUE") + with temp_db.transaction(): + temp_db.command("sql", "INSERT INTO Pair SET a = 1, b = 2, tag = 'x'") + assert temp_db.lookup_by_key("Pair", ["a", "b"], [1, 2]).get("tag") == "x" + assert temp_db.lookup_by_key("Pair", ["a", "b"], [1, 3]) is None + + +def test_plain_drop_type_refuses_a_graph_type_with_records_and_unsafe_drops_it(temp_db): + schema = temp_db.schema + schema.create_vertex_type("Person") + with temp_db.transaction(): + temp_db.command("sql", "CREATE VERTEX Person SET name = 'a'") + with pytest.raises(ArcadeDBError): + temp_db.command("sql", "DROP TYPE Person") + assert schema.exists_type("Person") is True + temp_db.command("sql", "DROP TYPE Person UNSAFE") + assert schema.exists_type("Person") is False + + # drop_type drops a type with its records (also pinned in test_schema.py) + schema.create_vertex_type("Person") + with temp_db.transaction(): + temp_db.command("sql", "CREATE VERTEX Person SET name = 'a'") + schema.drop_type("Person") + assert schema.exists_type("Person") is False