README.md

# Ex4j

An Ecto-inspired Cypher DSL and Neo4j driver for Elixir.

Ex4j lets you build **parameterized Cypher queries** using Elixir macros, protocols, and pipe-based composition — no raw strings required. All values become query parameters (`$p0`, `$p1`, ...) to prevent injection and enable Neo4j query plan caching.

Powered by [Boltx](https://github.com/sagastume/boltx) (Bolt 5.0–5.4, Neo4j 5.x) and [Ecto](https://github.com/elixir-ecto/ecto) for schema validation.

## Installation

Add `ex4j` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:ex4j, "~> 0.2.0"}
  ]
end
```

Configure the Neo4j connection:

```elixir
# config/config.exs
config :ex4j, Boltx,
  url: "bolt://localhost:7687",
  basic_auth: [username: "neo4j", password: "your_password"],
  pool_size: 10

# For Neo4j Aura (cloud), use neo4j+s:// and specify the database name:
config :ex4j, Boltx,
  url: "neo4j+s://your-instance-id.databases.neo4j.io",
  basic_auth: [username: "your_username", password: "your_password"],
  database: "your-database-name",
  pool_size: 5
```

Define a Repo module for executing queries:

```elixir
defmodule MyApp.Repo do
  use Ex4j.Repo, otp_app: :my_app
end
```

Add the Repo config:

```elixir
# config/config.exs
config :my_app, MyApp.Repo, []
```

The docs can be found at <https://hexdocs.pm/ex4j>.

## Defining Schemas

### Nodes

```elixir
defmodule MyApp.User do
  use Ex4j.Schema

  node "User" do
    field(:name, :string)
    field(:age, :integer)
    field(:email, :string)
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name, :age, :email])
    |> validate_required([:name, :email])
    |> validate_format(:email, ~r/@/)
    |> validate_inclusion(:age, 18..100)
  end
end
```

### Comments

```elixir
defmodule MyApp.Comment do
  use Ex4j.Schema

  node "Comment" do
    field(:content, :string)
  end
end
```

### Relationships

```elixir
defmodule MyApp.HasComment do
  use Ex4j.Schema

  relationship "HAS_COMMENT" do
    from(MyApp.User)
    to(MyApp.Comment)
    field(:created_at, :utc_datetime)
  end
end
```

### Multi-Label Nodes

```elixir
defmodule MyApp.Admin do
  use Ex4j.Schema

  node ["Person", "Admin"] do
    field(:name, :string)
    field(:role, :string)
  end
end
```





All schemas automatically get:
- Ecto `embedded_schema` with a `:uuid` primary key
- `Ecto.Changeset` functions imported
- `new/1` and `new/2` constructors for creating structs from maps
- `__schema__/1` introspection callbacks for query building

## Building Queries

Import the query API to access all macros:

```elixir
import Ex4j.Query.API
```

### Simple Match + Return

```elixir
User
|> match(as: :u)
|> return([:u])
|> MyApp.Repo.all()
# => {:ok, [%{"u" => %MyApp.User{name: "Tiago", age: 38, ...}}]}
```

**Generated Cypher:**

```cypher
MATCH (u:User)
RETURN u
```

### Where with Macro Expressions

No more raw strings! Write Elixir expressions and they compile to parameterized Cypher:

```elixir
User
|> match(as: :u)
|> where([u], u.age > 18 and u.name == "Tiago")
|> return([:u])
|> limit(10)
|> MyApp.Repo.all()
```

**Generated Cypher:**

```cypher
MATCH (u:User)
WHERE (u.age > $p0 AND u.name = $p1)
RETURN u
LIMIT 10
-- params: %{"p0" => 18, "p1" => "Tiago"}
```

### Pin Operator for Runtime Values

Use `^` to inject runtime variables as parameters (just like Ecto):

```elixir
name = "Tiago"
min_age = 18

User
|> match(as: :u)
|> where([u], u.name == ^name and u.age >= ^min_age)
|> return([u], [:name, :age])
|> MyApp.Repo.all()
```

**Generated Cypher:**

```cypher
MATCH (u:User)
WHERE (u.name = $p0 AND u.age >= $p1)
RETURN u.name, u.age
-- params: %{"p0" => "Tiago", "p1" => 18}
```

### Relationship Traversal

```elixir
query()
|> match(User, as: :u)
|> match(Comment, as: :c)
|> edge(HasComment, as: :r, from: :u, to: :c, direction: :out)
|> where([u], u.name == ^user_name)
|> where([c], c.content =~ "Article")
|> return([:u, :c])
|> MyApp.Repo.all()
```

**Generated Cypher:**

```cypher
MATCH (u:User)-[r:HAS_COMMENT]->(c:Comment)
WHERE (u.name = $p0 AND c.content CONTAINS $p1)
RETURN u, c
```

### Relationship Directions

```elixir
# Outgoing: ->
edge(HasComment, as: :r, from: :u, to: :c, direction: :out)
# (u)-[r:HAS_COMMENT]->(c)

# Incoming: <-
edge(HasComment, as: :r, from: :u, to: :c, direction: :in)
# (u)<-[r:HAS_COMMENT]-(c)

# Any direction: -
edge(HasComment, as: :r, from: :u, to: :c, direction: :any)
# (u)-[r:HAS_COMMENT]-(c)
```

### Variable-Length Relationships

```elixir
query()
|> match(User, as: :u)
|> match(User, as: :friend)
|> edge(:KNOWS, as: :r, from: :u, to: :friend, direction: :out, length: 1..3)
|> return([:u, :friend])
|> MyApp.Repo.all()
```

**Generated Cypher:**

```cypher
MATCH (u:User)-[r:KNOWS*1..3]->(friend:User)
RETURN u, friend
```

## Where Operators

The `where` macro supports all common comparison and logical operators:

| Elixir Expression | Cypher Output |
|---|---|
| `u.age > 18` | `u.age > $p0` |
| `u.age >= 18` | `u.age >= $p0` |
| `u.age < 65` | `u.age < $p0` |
| `u.age <= 65` | `u.age <= $p0` |
| `u.name == "Tiago"` | `u.name = $p0` |
| `u.name != "Admin"` | `u.name <> $p0` |
| `u.age in [18, 25, 30]` | `u.age IN $p0` |
| `u.name =~ "pattern"` | `u.name CONTAINS $p0` |
| `starts_with(u.name, "T")` | `u.name STARTS WITH $p0` |
| `ends_with(u.name, "go")` | `u.name ENDS WITH $p0` |
| `is_nil(u.email)` | `u.email IS NULL` |
| `not is_nil(u.email)` | `NOT u.email IS NULL` |
| `expr1 and expr2` | `expr1 AND expr2` |
| `expr1 or expr2` | `expr1 OR expr2` |
| `^variable` | `$pN` (runtime parameter) |

### Multiple Where Clauses

Multiple `where` calls are combined with `AND`:

```elixir
User
|> match(as: :u)
|> where([u], u.age > 18)
|> where([u], u.name == "Tiago")
|> return([:u])
```

**Generated Cypher:**

```cypher
MATCH (u:User)
WHERE u.age > $p0 AND u.name = $p1
RETURN u
```

## Write Operations

### CREATE

```elixir
query()
|> create(User, as: :u, set: %{name: "Alice", age: 30, email: "alice@example.com"})
|> return([:u])
|> MyApp.Repo.run()
```

**Generated Cypher:**

```cypher
CREATE (u:User {name: $p0, age: $p1, email: $p2})
RETURN u
```

### CREATE Relationship

```elixir
query()
|> match(User, as: :u, where: %{email: "alice@example.com"})
|> match(Comment, as: :c, where: %{content: "Great article!"})
|> create(HasComment, as: :r, from: :u, to: :c, set: %{created_at: "2025-06-01T10:00:00Z"})
|> return([:r])
|> MyApp.Repo.run()
```

**Generated Cypher:**

```cypher
MATCH (u:User {email: $p0}), (c:Comment {content: $p1})
CREATE (u)-[r:HAS_COMMENT {created_at: $p2}]->(c)
RETURN r
```

You can also create relationships without properties:

```elixir
query()
|> match(User, as: :u, where: %{email: "bob@example.com"})
|> match(Comment, as: :c, where: %{content: "Great article!"})
|> create(HasComment, as: :r, from: :u, to: :c)
|> return([:r])
|> MyApp.Repo.run()
```

Or with a specific direction:

```elixir
# Incoming relationship
query()
|> match(User, as: :u, where: %{email: "alice@example.com"})
|> match(Comment, as: :c, where: %{content: "Great article!"})
|> create(HasComment, as: :r, from: :u, to: :c, direction: :in)
|> return([:r])
|> MyApp.Repo.run()
```

### MERGE

```elixir
query()
|> merge(User, as: :u, match: %{email: "alice@example.com"})
|> return([:u])
|> MyApp.Repo.run()
```

**Generated Cypher:**

```cypher
MERGE (u:User {email: $p0})
RETURN u
```

### SET

```elixir
query()
|> match(User, as: :u)
|> where([u], u.email == "alice@example.com")
|> set(:u, :name, "Alice Updated")
|> set(:u, :age, 31)
|> return([:u])
|> MyApp.Repo.run()
```

**Generated Cypher:**

```cypher
MATCH (u:User)
WHERE u.email = $p0
SET u.name = $p1, u.age = $p2
RETURN u
```

### DELETE

```elixir
# Simple delete
query()
|> match(User, as: :u)
|> where([u], u.name == "Alice")
|> delete(:u)
|> MyApp.Repo.run()

# Detach delete (removes all relationships first)
query()
|> match(User, as: :u)
|> where([u], u.name == "Alice")
|> delete(:u, detach: true)
|> MyApp.Repo.run()
```

**Generated Cypher:**

```cypher
MATCH (u:User)
WHERE u.name = $p0
DETACH DELETE u
```

### REMOVE

```elixir
query()
|> match(User, as: :u)
|> where([u], u.name == "Alice")
|> remove(:u, :email)
|> return([:u])
|> MyApp.Repo.run()
```

**Generated Cypher:**

```cypher
MATCH (u:User)
WHERE u.name = $p0
REMOVE u.email
RETURN u
```

## Advanced Features

### OPTIONAL MATCH

```elixir
query()
|> match(User, as: :u)
|> optional_match(Comment, as: :c)
|> return([:u, :c])
|> MyApp.Repo.all()
```

**Generated Cypher:**

```cypher
MATCH (u:User)
OPTIONAL MATCH (c:Comment)
RETURN u, c
```

### ORDER BY, SKIP, LIMIT

```elixir
User
|> match(as: :u)
|> return([:u])
|> order_by([u], asc: :name, desc: :age)
|> skip(10)
|> limit(25)
|> MyApp.Repo.all()
```

**Generated Cypher:**

```cypher
MATCH (u:User)
RETURN u
ORDER BY u.name, u.age DESC
SKIP 10
LIMIT 25
```

### WITH (Query Chaining)

```elixir
query()
|> match(User, as: :u)
|> with_query([:u])
|> return([:u])
|> MyApp.Repo.all()
```

**Generated Cypher:**

```cypher
MATCH (u:User)
WITH u
RETURN u
```

### UNWIND

```elixir
query()
|> unwind([1, 2, 3], as: :x)
|> return([:x])
|> MyApp.Repo.all()
```

**Generated Cypher:**

```cypher
UNWIND $p0 AS x
RETURN x
-- params: %{"p0" => [1, 2, 3]}
```

### UNION

```elixir
q1 =
  query()
  |> match(User, as: :u)
  |> where([u], u.age > 30)
  |> return(:u, [:name])

q2 =
  query()
  |> match(User, as: :u)
  |> where([u], u.age < 20)
  |> return(:u, [:name])

union(q1, q2)      # UNION (distinct)
union(q1, q2, :all) # UNION ALL
|> MyApp.Repo.all()
```

### CALL Subqueries

```elixir
subquery =
  query()
  |> match(Comment, as: :c)
  |> return([:c])

query()
|> match(User, as: :u)
|> call(subquery)
|> return([:u, :c])
|> MyApp.Repo.all()
```

**Generated Cypher:**

```cypher
MATCH (u:User)
CALL {
  MATCH (c:Comment)
  RETURN c
}
RETURN u, c
```

## Dynamic Queries

Build queries at runtime based on user input or conditions:

```elixir
import Ex4j.Query.API

min_age = 18
name = "Tiago"

dyn = dynamic([u], u.age > ^min_age and u.name == ^name)

User
|> match(as: :u)
|> where(^dyn)
|> return([:u])
|> MyApp.Repo.all()
```

## Fragments

For Cypher syntax not covered by the DSL, use `fragment/1+` to embed raw Cypher with safe parameter binding:

```elixir
User
|> match(as: :u)
|> where([u], fragment("u.score > duration(?)", "P1Y"))
|> return([:u])
|> MyApp.Repo.all()
```

**Generated Cypher:**

```cypher
MATCH (u:User)
WHERE u.score > duration($p0)
RETURN u
-- params: %{"p0" => "P1Y"}
```

Each `?` placeholder becomes a parameterized value. Never interpolate user input directly — always use `?` placeholders.

## Raw Cypher Queries

For full control, pass a raw Cypher string directly:

```elixir
MyApp.Repo.query("MATCH (n:User) RETURN n LIMIT 25")

# With parameters
MyApp.Repo.query("MATCH (n:User) WHERE n.age > $age RETURN n", %{"age" => 18})
```

## Transactions

```elixir
MyApp.Repo.transaction(fn ->
  MyApp.Repo.run(create_user_query)
  MyApp.Repo.run(create_relationship_query)
end)
```

## Debugging Queries

Inspect the generated Cypher and parameters without executing:

```elixir
import Ex4j.Query.API

{cypher, params} =
  User
  |> match(as: :u)
  |> where([u], u.age > 18)
  |> return([:u])
  |> to_cypher()

IO.puts(cypher)
# MATCH (u:User)
# WHERE u.age > $p0
# RETURN u

IO.inspect(params)
# %{"p0" => 18}
```

Or get just the Cypher string:

```elixir
cypher_string =
  User
  |> match(as: :u)
  |> return([:u])
  |> cypher()
# "MATCH (u:User)\nRETURN u"
```

## Changeset Validation

Schemas integrate with Ecto changesets for validation:

```elixir
changeset = User.changeset(%User{}, %{"name" => "Tiago", "email" => "tiago@test.com"})

if changeset.valid? do
  user = Ecto.Changeset.apply_action!(changeset, :create)
  # proceed with creating the node...
end
```

Graph-specific validations are available via `Ex4j.Changeset`:

```elixir
changeset
|> Ex4j.Changeset.validate_node_label()
|> Ex4j.Changeset.validate_neo4j_type(:age)
```

## Cypher 25 Support

Ex4j includes a comprehensive Cypher functions registry supporting Cypher 25 additions:

- **Aggregation**: `count`, `sum`, `avg`, `min`, `max`, `collect`, `percentile_cont`, `percentile_disc`
- **Scalar**: `coalesce`, `head`, `last`, `size`, `length`, `keys`, `labels`, `type`, `id`, `element_id`
- **String**: `trim`, `to_lower`, `to_upper`, `replace`, `substring`, `split`
- **Math**: `abs`, `ceil`, `floor`, `round`, `sqrt`, `log`, `rand`
- **Temporal**: `date`, `datetime`, `duration`, `time`, `timestamp`
- **Spatial**: `point`, `distance`
- **Cypher 25 Vectors**: `vector`, `vector_dimension_count`, `vector_distance`, `vector_norm`

## Sample Data for Testing

A complete seed script using the `User`, `Comment`, and `HasComment` schemas.
Paste this into `priv/repo/seeds.exs` or run it in `iex -S mix`.

```elixir
import Ex4j.Query.API

alias MyApp.{User, Comment, HasComment}

# --- Indexes & Constraints ---------------------------------------------------

MyApp.Repo.query("CREATE CONSTRAINT user_email_unique IF NOT EXISTS FOR (u:User) REQUIRE u.email IS UNIQUE")
MyApp.Repo.query("CREATE INDEX user_name_index IF NOT EXISTS FOR (u:User) ON (u.name)")
MyApp.Repo.query("CREATE INDEX comment_content_index IF NOT EXISTS FOR (c:Comment) ON (c.content)")

# --- Nodes: Users ------------------------------------------------------------

users = [
  %{name: "Tiago", age: 38, email: "tiago@example.com"},
  %{name: "Alice", age: 30, email: "alice@example.com"},
  %{name: "Bob",   age: 25, email: "bob@example.com"}
]

for attrs <- users do
  query()
  |> create(User, as: :u, set: attrs)
  |> return([:u])
  |> MyApp.Repo.run()
end

# --- Nodes: Comments ----------------------------------------------------------

comments = [
  %{content: "Great article on Elixir!"},
  %{content: "Neo4j is awesome for graph data"},
  %{content: "Loving the Ex4j DSL"}
]

for attrs <- comments do
  query()
  |> create(Comment, as: :c, set: attrs)
  |> return([:c])
  |> MyApp.Repo.run()
end

# --- Edges: HAS_COMMENT (with optional properties) ---------------------------

edges = [
  {"tiago@example.com", "Great article on Elixir!",          %{created_at: "2025-06-01T10:00:00Z"}},
  {"tiago@example.com", "Neo4j is awesome for graph data",   %{created_at: "2025-06-02T14:30:00Z"}},
  {"alice@example.com", "Loving the Ex4j DSL",               %{created_at: "2025-06-03T09:15:00Z"}},
  {"bob@example.com",   "Great article on Elixir!",          %{}}  # no properties
]

for {email, content, props} <- edges do
  query()
  |> match(User, as: :u, where: %{email: email})
  |> match(Comment, as: :c, where: %{content: content})
  |> create(HasComment, as: :r, from: :u, to: :c, set: props)
  |> return([:r])
  |> MyApp.Repo.run()
end

# --- Verify ------------------------------------------------------------------

# All users with their comments
query()
|> match(User, as: :u)
|> match(Comment, as: :c)
|> edge(HasComment, as: :r, from: :u, to: :c, direction: :out)
|> return([:u, :r, :c])
|> MyApp.Repo.all()

# Users older than 25
User
|> match(as: :u)
|> where([u], u.age > 25)
|> return([u], [:name, :email])
|> order_by([u], asc: :name)
|> MyApp.Repo.all()

# Bob's comments (should be 1, no properties on the edge)
query()
|> match(User, as: :u)
|> match(Comment, as: :c)
|> edge(HasComment, as: :r, from: :u, to: :c, direction: :out)
|> where([u], u.name == "Bob")
|> return([:c, :r])
|> MyApp.Repo.all()
```

### Cleanup

```elixir
MyApp.Repo.query("MATCH (n) DETACH DELETE n")
MyApp.Repo.query("DROP CONSTRAINT user_email_unique IF EXISTS")
MyApp.Repo.query("DROP INDEX user_name_index IF EXISTS")
MyApp.Repo.query("DROP INDEX comment_content_index IF EXISTS")
```

## Architecture

| Module | Responsibility |
|---|---|
| `Ex4j.Schema` | Define node and relationship schemas with labels, fields, and Ecto validation |
| `Ex4j.Query` | Immutable query struct that accumulates clauses via pipe composition |
| `Ex4j.Query.API` | Public macro DSL (`match`, `where`, `return`, `create`, `edge`, etc.) |
| `Ex4j.Query.Compiler` | Compiles Elixir AST into parameterized expression structs |
| `Ex4j.Cypher` | Generates `{cypher_string, params_map}` from query structs |
| `Ex4j.Cypher.Fragment` | Handles raw Cypher fragments with `?` parameter binding |
| `Ex4j.Queryable` | Protocol allowing schemas and queries to be used interchangeably |
| `Ex4j.Repo` | Execution interface (like Ecto.Repo) with `all`, `one`, `run`, `query`, `transaction` |
| `Ex4j.Bolt` | Boltx adapter for Neo4j communication |
| `Ex4j.Changeset` | Graph-aware validation extensions |

## License

Ex4j source code is released under Apache License 2.0.

Check [NOTICE](NOTICE) and [LICENSE](LICENSE) files for more information.