Skip to content

Lookups that ignore case

Logging in by email whatever its case is a lookup by eq_ignore_case alone, and on its own it reads every node of the label. Mark the field CaseInsensitive() and give it an index that ignores case:

class Account(NodeFragment, labels=["Account"]):
id: Annotated[str, NodeId()]
email: Annotated[str, CaseInsensitive(), Unique(ignore_case=True)]
await gom.load_all(Account, where=refs(Account).email.eq_ignore_case("Ada@Example.com"))
  • The copy. Every save, create, update, save_all, patch and patch_all, and the save of a view’s root and related nodes, writes a lowercased copy of the property beside it, under __drivine.lower.<property> (the name it is stored under, after GraphProperty). The database lowercases it, as it lowercases the value a lookup compares with, so drivine-python, drivine4j and TypeScript’s Drivine store and match the same thing. A property with no value has no copy. A load doesn’t read it, and a flat PropertyBag never holds it. The copy is no part of whether a save changed the node, so a save that only fills one in keeps the node’s stamp. Cypher you send yourself doesn’t: see below.

  • The index. RangeIndex(ignore_case=True), Unique(ignore_case=True), range_index(label, property, ignore_case=True) and uniqueness_constraint(label, property, ignore_case=True) are made over the copy, under their own names, <Label>_<property>_ci_range and _ci_unique. One that ignores case is refused on a field that isn’t CaseInsensitive, since no save would keep its copy. A uniqueness constraint that ignores case refuses A@x.com beside a@x.com. FalkorDB can’t make one: GRAPH.CONSTRAINT takes no property name with a dot in it (FalkorDB#551), so it is refused there; its range index works.

  • Filling it in. Ensuring an item that ignores case first fills the copy in on every node of the label that lacks it, 10,000 nodes a statement, and records that every node has it: on the store’s DrivineStore node on the graph engines, in the graph’s catalog on Oracle and Postgres. An item made anew, or recreated, fills the copies in again.

  • The lookup. Once the copy is recorded, eq_ignore_case and contains_ignore_case on the field compare with the copy: in a load, a count, a delete_all, a grouped read and a patch’s condition, on the root’s own fields. Until then, and for an empty value or a related node’s field, they lowercase the property, as for any field: the same answer, without the index. A manager knows at once what it recorded, and reads what another recorded once a minute.

  • A long text. On Oracle the copy is written whole, as a CLOB, however long the text, but an index over it holds no copy past 4,000 bytes. So a lookup by a value that may lowercase past that, one of more than 1,333 characters (counted in UTF-16 units, as drivine4j counts them, so a character outside the BMP counts twice), compares the copy as a CLOB on every node of the label instead of reading the index. Neo4j refuses to save a text too large for its range index over the copy.

  • Cypher of your own keeps no copy, on any store. Drivine runs a statement you send as written on the graph engines, and on Postgres, Oracle and SQLite compiles it to SQL and adds nothing to it. So a SET, CREATE, MERGE or REMOVE of the property leaves the copy as it was, or leaves the node without one, and so does SQL of your own. Once the copy is recorded, a lookup misses a node so written, or finds it by its old value. A node saved by a process whose fragment isn’t yet CaseInsensitive, as while an upgrade rolls out, is missed the same way. Set the copy in the same statement, in a SET of its own after the one that writes the property, since an engine may read every value of one SET before it writes any. The same SET after a REMOVE of the property removes the copy.

    MATCH (a:Account {id: $id})
    SET a.email = $email
    SET a.`__drivine.lower.email` = toLower(a.email)

    lower_copies.set_clause("a", "email") gives that last item. Or, after the writes, recreate the index that ignores case, which fills the copy in again on every node of the label:

    await gom.indexes.recreate(range_index("Account", "email", ignore_case=True))

    A constraint that ignores case has no recreate: drop it and ensure it again, which fills the copies in again as it makes the constraint anew.