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..b4ea56f --- /dev/null +++ b/docs/modules/ROOT/pages/how-to/values-and-records.adoc @@ -0,0 +1,430 @@ += 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 short version: + +* 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 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, 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: + +[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. 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 +---- + +== Read a row + +`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")` 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`. + +[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]] +== 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. 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()`. `record.delete()` removes a record, also inside a transaction. + +[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 +---- + +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 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] +---- +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 +---- + +To change a record, call `modify()`, `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, and a call without such an index raises `ArcadeDBError`. It returns a matching record, 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 +---- + +=== 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]. + +== 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_*` +| `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. `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] +---- +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 +---- + +The same schema as statements: + +[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. + +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] +---- +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..282f709 --- /dev/null +++ b/tests/test_values_and_records_behavior.py @@ -0,0 +1,590 @@ +"""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 + +import arcadedb +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 + + +@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 = ?", 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 + + +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_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") + 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"], + ): + 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): + 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() == [] + 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 + + +# --- 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 + + +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