diff --git a/AGENTS.md b/AGENTS.md index d4b3e7e..707b734 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ ADR justifying it. | **v0.2 (APIs)** | Document, Schema, Branch, Streaming, Document.query/stream, guides, Livebook | **Done** | | **v0.3 (versioned workflows)** | Commit, Diff, Merge (rebase), WOQL DSL v0.1, TerminusDB 12 compat, integration tests | **Done** | | **v0.3.1 (WOQL v0.2)** | ~70 WOQL operators (Tier 1+2), 4-wrapper value model, dual path DSL, ADR-0008 | **Done** | -| v0.3.2+ | GraphQL, temporal/Allen WOQL, RDF list library, CSV/IO, benchmarks, tutorials | Pending | +| v0.3.2+ | GraphQL, temporal/Allen WOQL, RDF list library, CSV/IO, range queries, prefix mgmt, squash/reset/optimize, patch/apply, triples, remote (clone/fetch/push/pull), WOQL streaming, benchmarks, tutorials, ADR-0009..0013 | **Done** | | v0.4 (Ecto) | `use TerminusDB.Schema` macro, schema generation, optional `:ecto` dep; research full `Ecto.Adapter` feasibility | Pending | | v0.5 (ExDatalog) | `TerminusDB.Datalog` bridge (`to_woql`, `to_jsonld`, `from_triples`), optional `:datalog` dep | Pending | | Release | CHANGELOG, release notes, migration guide, Hex.pm review, final review | Pending | diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b4aeb..64f9ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,121 @@ All notable changes to `terminusdb_ex` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.2] — 2026-06-26 + +### Added — GraphQL (ADR-0009) + +- `TerminusDB.GraphQL` module: `query/3`, `mutate/3`, `introspect/2` — thin + HTTP wrapper for the `/api/graphql/{org}/{db}` endpoint. + +### Added — Temporal / Allen WOQL (ADR-0010) + +- `interval/3`, `interval_start_duration/3`, `interval_duration_end/3`, + `interval_relation/5`, `interval_relation_typed/3`, `date_duration/3`, + `day_after/2`, `day_before/2`, `weekday/2`, `weekday_sunday_start/2`, + `iso_week/3`, `month_start_date/2`, `month_end_date/2`, + `month_start_dates/3`, `month_end_dates/3`, `in_range/3`, `sequence/5`, + `range_min/2`, `range_max/2`. + +### Added — RDF list library (ADR-0011) + +- `TerminusDB.WOQL.RDFList` module with 17 functions: `rdflist_list/2`, + `rdflist_peek/2`, `rdflist_last/2`, `rdflist_nth0/3`, `rdflist_nth1/3`, + `rdflist_member/2`, `rdflist_length/2`, `rdflist_pop/2`, `rdflist_push/3`, + `rdflist_append/3`, `rdflist_clear/2`, `rdflist_empty/1`, + `rdflist_is_empty/1`, `rdflist_slice/4`, `rdflist_insert/4`, + `rdflist_drop/2`, `rdflist_swap/3`. + +### Added — CSV / IO (ADR-0012) + +- `WOQL.get/2`, `WOQL.put/3`, `WOQL.woql_as/1`, `WOQL.file/2`, + `WOQL.remote/2`, `WOQL.post/2`. + +### Added — Range queries + +- `triple_slice/5`, `quad_slice/6`, `triple_slice_rev/5`, + `quad_slice_rev/6`, `triple_next/4`, `quad_next/5`, + `triple_previous/4`, `quad_previous/5`. + +### Added — Client API gaps + +- `TerminusDB.Prefix` module: `get/2`, `add/3`, `update/3`, `upsert/3`, + `delete/2`, `all/2`. +- `Branch.squash/2`, `Branch.reset/3`, `Database.optimize/2`. +- `TerminusDB.Patch` struct: `from_json/1`, `to_json/1`, `update/1`, + `before/1`, `copy/1`. +- `Diff.diff_object/2`, `Diff.diff_version/2`, `Diff.patch/2`, + `Diff.patch_resource/2`, `Diff.apply/3`. +- `Commit.document_history/3`. +- `TerminusDB.Triples` module: `get/2`, `update/3`, `insert/3`. +- `WOQL.execute_stream/3` — streaming WOQL results. +- `TerminusDB.Remote` module: `clone/4`, `fetch/2`, `push/3`, `pull/3`. + +### Added — Benchmarks + +- `benchee` dev dependency. +- `TerminusDB.Benchmark` helper module. +- 5 benchmark suites in `bench/`. + +### Added — Tutorials + +- `guides/graphql-guide.md` — GraphQL queries, mutations, filters, pagination. +- `guides/temporal-allen-guide.md` — Intervals, Allen relations, calendar ops. +- `guides/csv-import-guide.md` — CSV reading/writing with WOQL. +- `guides/rdf-list-guide.md` — RDF list manipulation. + +### Changed + +- `rdflist_push/2` changed to `rdflist_push/3`: now takes a `new_head_var` + parameter so callers can update their reference to the new list head. +- `Benchmark.seed_database/2` return type changed from `Config.t()` to + `{Config.t(), String.t()}` so callers can clean up the database after + benchmarking. +- `Patch.before/1` and `Patch.update/1` asymmetry documented: `before/1` + preserves non-SwapValue fields for full state reconstruction; `update/1` + only includes changed (SwapValue) fields. +- `WOQL.execute_stream/3` now uses lazy `Stream.map` instead of eager + `Enum.map`, and safe `Jason.decode/1` instead of `Jason.decode!/1`. +- `GraphQL.query/3`, `GraphQL.mutate/3`, `GraphQL.introspect/2`, and all + `Prefix.*` functions now return `{:error, %Error{reason: :config}}` when + no database is scoped, instead of raising. + +### Fixed — Review fixes (review-0.3.2.md) + +- `rdflist_nth0`/`rdflist_nth1` variable-index path: fixed missing `v.dec` + variable in localize map; added `eval(minus(...))` for decrement; unrolled + recursion to avoid infinite Elixir recursion (C1). +- `rdflist_slice/4`: fixed silently ignored `end_val` parameter; rewrote + with proper start/end bounds using cell navigation + collect (C2). +- `rdflist_swap/3`: fixed no-op implementation; added `rdflist_cell_at` + helper and delete_triple/add_triple write operations for both cells (C3). +- `rdflist_drop/2`: fixed to operate on the cell at `position`, not the list + head; position 0 deletes from head, position > 0 navigates to cell and + relinks parent (H1). +- `rdflist_clear/2`: wrapped delete_triple calls inside `opt(and_([triple, + delete]))` blocks to guard against unbound variables (M4). +- `encode_arithmetic/1`: added clause for non-variable binary strings (L2). +- `Benchmark.seed_database/2`: returns `{config, db_name}` for cleanup (L4). +- RDFList tests: added structural AST assertions verifying write operations + (AddTriple/DeleteTriple) in swap/drop, dec variable in nth, Optional + blocks in clear (L5). +- `encode_value/1`: added list encoding for `range_min`/`range_max` + operands (uses `DataValue` wrapper with `list` field). + +### Deferred to v0.3.3+ + +- GraphQL builder DSL. +- `graph/1` context setter. +- Data version headers (`last_data_version`/`get_data_version`). +- Gzip compression for large document inserts. +- Macro sugar layer (`TerminusDB.WOQL.Macros`). + +### Deferred to v0.4+ + +- Access control (organizations, users, roles, capabilities). +- DataFrame (Explorer) integration. +- Ecto integration (`TerminusDB.Schema` macro). + ## [0.3.1] — 2026-06-25 ### Added — WOQL DSL v0.2 (ADR-0008) diff --git a/README.md b/README.md index f35d7c3..70ff0cb 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,13 @@ graph database with built-in version control. It is built on [Req](https://hexdocs.pm/req) and treats connection context as **immutable data**, making it safe for concurrent use. -> **Status:** v0.3.1. `Config`, `Error`, `Telemetry`, `Client`, `Database`, +> **Status:** v0.3.2. `Config`, `Error`, `Telemetry`, `Client`, `Database`, > `Document` (with streaming), `Schema`, `Branch`, `Commit`, `Diff`, `Merge`, -> and `WOQL` (v0.2 DSL — ~70 operators) are implemented and tested. GraphQL, -> Ecto, and ExDatalog integrations are planned for later milestones. See -> `ARCHITECTURE.md` and `AGENTS.md` for the roadmap. +> `Patch`, `Prefix`, `Triples`, `Remote`, `GraphQL`, and `WOQL` (v0.2 DSL — +> ~100 operators including temporal/Allen, CSV/IO, range queries, RDF list +> library) are implemented and tested. Ecto and ExDatalog integrations are +> planned for later milestones. See `ARCHITECTURE.md` and `AGENTS.md` for +> the roadmap. ## Installation @@ -24,7 +26,7 @@ Add `terminusdb_client` to your dependencies in `mix.exs`: ```elixir def deps do [ - {:terminusdb_client, "~> 0.3.1"} + {:terminusdb_client, "~> 0.3.2"} ] end ``` @@ -90,6 +92,49 @@ TerminusDB.Document.stream(config, type: "Person") {:ok, _} = TerminusDB.Database.delete(config, "mydb") ``` +## GraphQL + +TerminusDB auto-generates a GraphQL endpoint from your document schema. +Query and mutate using raw GraphQL strings: + +```elixir +# Query all Person documents +{:ok, result} = TerminusDB.GraphQL.query(config, "{ Person { name age } }") +# => {:ok, %{data: %{"Person" => [%{"name" => "Alice", "age" => 30}]}, errors: nil}} + +# Insert via mutation +{:ok, _} = TerminusDB.GraphQL.mutate(config, "mutation { _insertDocuments(json: \"...\") }") + +# Introspect the auto-generated schema +{:ok, schema} = TerminusDB.GraphQL.introspect(config) +``` + +## WOQL queries + +The WOQL DSL provides ~100 composable operators for graph queries: + +```elixir +import TerminusDB.WOQL + +# Query with select, triple pattern, and limit +{:ok, result} = + WOQL.execute(config, + select(["v:Name", "v:Age"], + limit(10, + and_([ + triple("v:Person", "name", "v:Name"), + triple("v:Person", "age", "v:Age"), + triple("v:Person", "rdf:type", iri("@schema:Person")) + ]) + ) + ) + ) + +# Stream WOQL results (lazy, constant memory) +{:ok, stream} = WOQL.execute_stream(config, query) +Enum.to_list(stream) +``` + All public functions return `{:ok, result}` or `{:error, %TerminusDB.Error{}}`. Each `!/1`-suffixed variant raises `TerminusDB.Error` instead. @@ -104,7 +149,8 @@ config = TerminusDB.Config.new(endpoint: "http://localhost:6363", token: "tok_ab ## Telemetry Every operation emits `[:terminusdb, , :start]` and `[:stop]` events -(`` is `:database`, `:document`, `:query`, `:branch`, `:merge`, `:diff`, or +(`` is `:database`, `:document`, `:query`, `:branch`, `:merge`, `:diff`, +`:commit`, `:woql`, `:graphql`, `:prefix`, `:triples`, `:remote`, or `:connection`). Attach with `:telemetry.attach_many/4`. See `TerminusDB.Telemetry` and ADR-0005. diff --git a/bench/document_crud_bench.exs b/bench/document_crud_bench.exs new file mode 100644 index 0000000..96deddc --- /dev/null +++ b/bench/document_crud_bench.exs @@ -0,0 +1,36 @@ +# Document CRUD Benchmark +# +# Run: mix run bench/document_crud_bench.exs +# Requires: docker compose up -d + +alias TerminusDB.{Benchmark, Config, Document} + +endpoint = System.get_env("TERMINUSDB_URL", "http://localhost:6363") + +config = + Config.new(endpoint: endpoint, user: "admin", key: "root") + |> Config.with_database("bench_crud_#{:erlang.unique_integer([:positive])}") + +# Seed +{seeded, db_name} = Benchmark.seed_database(config, 100) + +Benchee.run( + %{ + "insert 1 doc" => fn -> + Document.insert(seeded, %{"@type" => "Person", "name" => "Bench_#{:erlang.unique_integer([:positive])}", "age" => 25}, + author: "admin", message: "bench insert" + ) + end, + "get by id" => fn -> + Document.get(seeded, id: "Person/Person_1") + end, + "get by type" => fn -> + Document.get(seeded, type: "Person", as_list: true, count: 10) + end + }, + time: 10, + memory_time: 2 +) + +# Cleanup +TerminusDB.Database.delete(config, db_name, force: true) diff --git a/bench/path_parse_bench.exs b/bench/path_parse_bench.exs new file mode 100644 index 0000000..4fdb2e1 --- /dev/null +++ b/bench/path_parse_bench.exs @@ -0,0 +1,21 @@ +# Path Parse Benchmark +# +# Run: mix run bench/path_parse_bench.exs + +alias TerminusDB.WOQL.Path + +Benchee.run( + %{ + "simple pred" => fn -> Path.parse("friend") end, + "star" => fn -> Path.parse("friend*") end, + "plus" => fn -> Path.parse("friend+") end, + "inverse" => fn -> Path.parse(" fn -> Path.parse("friend|foe") end, + "sequence" => fn -> Path.parse("friend,foe") end, + "quantified" => fn -> Path.parse("friend*{1,3}") end, + "complex" => fn -> Path.parse("(friend,foe)+{2,5}") end, + "inverse star" => fn -> Path.parse(" Config.with_database("bench_stream_#{:erlang.unique_integer([:positive])}") + +# Seed with 500 documents +{seeded, db_name} = Benchmark.seed_database(config, 500) + +Benchee.run( + %{ + "get all (as_list)" => fn -> + Document.get(seeded, type: "Person", as_list: true) + end, + "stream (collect)" => fn -> + seeded + |> Document.stream(type: "Person") + |> Enum.to_list() + end + }, + time: 10, + memory_time: 2 +) + +# Cleanup +TerminusDB.Database.delete(config, db_name, force: true) diff --git a/bench/woql_decode_bench.exs b/bench/woql_decode_bench.exs new file mode 100644 index 0000000..24d5b47 --- /dev/null +++ b/bench/woql_decode_bench.exs @@ -0,0 +1,32 @@ +# WOQL Decode Benchmark +# +# Run: mix run bench/woql_decode_bench.exs + +alias TerminusDB.WOQL + +# Pre-encode queries for decoding +triple_jsonld = WOQL.to_jsonld(WOQL.triple("v:S", "p", "v:O")) + +and_jsonld = WOQL.to_jsonld(WOQL.and_([ + WOQL.triple("v:S", "name", "v:Name"), + WOQL.triple("v:S", "age", "v:Age"), + WOQL.triple("v:S", "rdf:type", WOQL.iri("@schema:Person")) +])) + +select_jsonld = WOQL.to_jsonld(WOQL.select(["v:Name"], WOQL.limit(10, WOQL.triple("v:P", "name", "v:Name")))) + +path_jsonld = WOQL.to_jsonld(WOQL.path("v:S", "friend*{1,3}", "v:O")) + +interval_jsonld = WOQL.to_jsonld(WOQL.interval_relation("v:R", "v:XS", "v:XE", "v:YS", "v:YE")) + +Benchee.run( + %{ + "triple" => fn -> WOQL.from_jsonld(triple_jsonld) end, + "and_ (3 ops)" => fn -> WOQL.from_jsonld(and_jsonld) end, + "select + limit" => fn -> WOQL.from_jsonld(select_jsonld) end, + "path" => fn -> WOQL.from_jsonld(path_jsonld) end, + "interval_relation" => fn -> WOQL.from_jsonld(interval_jsonld) end + }, + time: 5, + memory_time: 2 +) diff --git a/bench/woql_encode_bench.exs b/bench/woql_encode_bench.exs new file mode 100644 index 0000000..7f2977d --- /dev/null +++ b/bench/woql_encode_bench.exs @@ -0,0 +1,41 @@ +# WOQL Encode Benchmark +# +# Run: mix run bench/woql_encode_bench.exs + +alias TerminusDB.{WOQL, Benchmark} + +Benchee.run( + %{ + "triple" => fn -> WOQL.to_jsonld(WOQL.triple("v:S", "p", "v:O")) end, + "and_ (3 ops)" => fn -> + WOQL.to_jsonld(WOQL.and_([ + WOQL.triple("v:S", "name", "v:Name"), + WOQL.triple("v:S", "age", "v:Age"), + WOQL.triple("v:S", "rdf:type", WOQL.iri("@schema:Person")) + ])) + end, + "select + limit" => fn -> + WOQL.to_jsonld(WOQL.select(["v:Name"], WOQL.limit(10, WOQL.triple("v:P", "name", "v:Name")))) + end, + "order_by" => fn -> + WOQL.to_jsonld(WOQL.order_by([{"v:Age", :asc}], WOQL.and_([ + WOQL.triple("v:P", "name", "v:Name"), + WOQL.triple("v:P", "age", "v:Age") + ]))) + end, + "path (string)" => fn -> + WOQL.to_jsonld(WOQL.path("v:S", "friend*{1,3}", "v:O")) + end, + "interval_relation" => fn -> + WOQL.to_jsonld(WOQL.interval_relation("v:R", "v:XS", "v:XE", "v:YS", "v:YE")) + end, + "triple_slice" => fn -> + WOQL.to_jsonld(WOQL.triple_slice("v:S", "v:P", "v:O", 10, 100)) + end, + "get (CSV)" => fn -> + WOQL.to_jsonld(WOQL.get(WOQL.woql_as([{"name", "v:Name"}]), WOQL.file("data.csv"))) + end + }, + time: 5, + memory_time: 2 +) diff --git a/coveralls.json b/coveralls.json index 97d3964..e02d682 100644 --- a/coveralls.json +++ b/coveralls.json @@ -1,5 +1,11 @@ { "coverage_options": { - "minimum_coverage": 80 - } + "minimum_coverage": 80, + "treat_no_relevant_lines_as_covered": true + }, + "skip_files": [ + "lib/terminus_db/benchmark.ex", + "lib/terminus_db/application.ex", + "test/support/helpers.ex" + ] } diff --git a/docs/adr/0009-graphql-strategy.md b/docs/adr/0009-graphql-strategy.md new file mode 100644 index 0000000..36c42cc --- /dev/null +++ b/docs/adr/0009-graphql-strategy.md @@ -0,0 +1,70 @@ +# ADR-0009: GraphQL thin wrapper strategy + +Date: 2026-06-25 +Status: Accepted (implementation in v0.3.2) + +## Context + +TerminusDB 12 exposes a GraphQL endpoint at `/api/graphql/{org}/{db}` with +auto-generated schema (from the database's document schema), queries (filter, +limit, offset, orderBy, backlinks, path queries, count), and mutations +(insert/replace/delete documents with optional commit info). GraphiQL is +available at `/api/graphiql/{org}/{db}`. + +Neither the Python nor the Elixir client currently implements GraphQL support. +The v0.3.2 release adds it as a net-new feature. + +## Decision + +Implement a **thin HTTP wrapper** — `TerminusDB.GraphQL` module with three +functions: + +- `query/3` — sends a raw GraphQL query string via POST, returns + `{:ok, %{data: data, errors: errors}}` or `{:error, %Error{}}`. +- `mutate/3` — same as `query/3` but conventionally used for mutations. +- `introspect/2` — sends a `__schema` introspection query, returns + `{:ok, schema_map}`. + +The user writes GraphQL query strings directly. No programmatic query builder +DSL is provided in v0.3.2. + +### Rationale + +- GraphQL is a query language with its own syntax; wrapping it in a builder DSL + adds complexity without much value — the user still needs to understand + GraphQL schema/types. +- A thin wrapper gets GraphQL working quickly and lets users leverage the + GraphiQL browser for schema exploration. +- A builder DSL can be layered on top later (v0.3.3+) once usage patterns + emerge. + +### Endpoint + +``` +POST /api/graphql/{org}/{db} +Content-Type: application/json +Authorization: Basic ... (or Token ...) + +{"query": "...", "variables": {...}} +``` + +### Telemetry + +`[:terminusdb, :graphql, :start|:stop]` — consistent with all other operations. + +## Consequences + +- Users must write GraphQL strings (no compile-time validation). +- No schema caching or introspection-based type checking. +- Error handling: GraphQL errors (in the `errors` field) are returned as + `{:ok, %{data: _, errors: _}}`, while HTTP/transport errors are + `{:error, %Error{}}`. + +## Alternatives considered + +1. **Programmatic query builder DSL** — rejected for v0.3.2 (too opinionated, + large surface area, can be added later). +2. **Absinthe integration** — rejected (adds a heavy runtime dep for something + TerminusDB already provides server-side). +3. **Code generation from introspection** — rejected (complex, needs build + step). diff --git a/docs/adr/0010-temporal-allen-woql.md b/docs/adr/0010-temporal-allen-woql.md new file mode 100644 index 0000000..3fc256d --- /dev/null +++ b/docs/adr/0010-temporal-allen-woql.md @@ -0,0 +1,78 @@ +# ADR-0010: Temporal / Allen interval algebra WOQL operators + +Date: 2026-06-25 +Status: Accepted (implementation in v0.3.2) + +## Context + +The gap analysis (`baoulo/reviews/gap-analysis-3.2.md`) identified 19 temporal +operators in the Python client that were absent from the Elixir client. These +cover interval construction, Allen's interval algebra relations, date +arithmetic, calendar operations, and range utilities. + +TerminusDB 12 supports these as first-class WOQL JSON-LD `@type` values. + +## Decision + +Implement all 19 operators as functional builders in the `WOQL` module, matching +the Python client's JSON-LD `@type` values exactly. + +### Operators + +**Interval constructors (3):** +- `interval/3` → `Interval` +- `interval_start_duration/3` → `IntervalStartDuration` +- `interval_duration_end/3` → `IntervalDurationEnd` + +**Allen relations (2):** +- `interval_relation/5` → `IntervalRelation` +- `interval_relation_typed/3` → `IntervalRelationTyped` + +**Date arithmetic (1):** +- `date_duration/3` → `DateDuration` + +**Day operations (2):** +- `day_after/2` → `DayAfter` +- `day_before/2` → `DayBefore` + +**Weekday (2):** +- `weekday/2` → `Weekday` +- `weekday_sunday_start/2` → `WeekdaySundayStart` + +**ISO week (1):** +- `iso_week/3` → `IsoWeek` + +**Month operations (4):** +- `month_start_date/2` → `MonthStartDate` +- `month_end_date/2` → `MonthEndDate` +- `month_start_dates/3` → `MonthStartDates` +- `month_end_dates/3` → `MonthEndDates` + +**Range utilities (4):** +- `in_range/3` → `InRange` +- `sequence/5` → `Sequence` (step/count default `nil`) +- `range_min/2` → `RangeMin` +- `range_max/2` → `RangeMax` + +### Value wrappers + +Temporal operands use the `Value` wrapper (same as comparison/string ops), +consistent with the 4-wrapper model from ADR-0008. + +### Encoder/decoder + +Each operator gets a dedicated `encode/1` clause in `Encoder` and a `decode/1` +clause in `Decoder`. `Sequence` omits `step`/`count` fields when `nil`. + +## Consequences + +- 19 new operators, 19 encoder clauses, 19 decoder clauses. +- ~57 new unit tests (builder + encode + round-trip per op). +- Property test generators extended for temporal ops. + +## Alternatives considered + +1. **Subset only** — rejected (all 19 are in the Python client; partial + coverage would be confusing). +2. **Separate `WOQL.Temporal` module** — rejected (ops are WOQL operators, not + a separate DSL; keeping them in `WOQL` matches Python). diff --git a/docs/adr/0011-rdf-list-library.md b/docs/adr/0011-rdf-list-library.md new file mode 100644 index 0000000..5aa1c02 --- /dev/null +++ b/docs/adr/0011-rdf-list-library.md @@ -0,0 +1,72 @@ +# ADR-0011: RDF list library composition + +Date: 2026-06-25 +Status: Accepted (implementation in v0.3.2) + +## Context + +The Python client provides 17 `rdflist_*` methods via a `WOQLLibrary` class, +accessed as `WOQLQuery().lib().rdflist_*(...)`. These methods compose WOQL +primitives (`triple`, `path`, `add_triple`, `delete_triple`, `idgen`, `eq`, +`length`, `group_by`) to manipulate RDF `rdf:List` structures. + +Each method uses `localize()` — a higher-order utility that generates +collision-free variable names via a global counter (`VarsUnique`) — to prevent +variable name clashes with the caller's scope. + +## Decision + +Implement a new `TerminusDB.WOQL.RDFList` module with all 17 functions. Each +function composes `WOQL.Query` structs using existing `WOQL.*` builders. + +### Variable collision avoidance + +Implement an internal `localize/1` helper using `:erlang.unique_integer/1` to +generate process-unique variable names (e.g., `"v:RDFList_Head_12345"`). This +is simpler than Python's `VarsUnique` class and avoids global mutable state. + +### Module placement + +`lib/terminus_db/woql/rdf_list.ex` — follows the existing sub-module pattern +(`woql/encoder.ex`, `woql/decoder.ex`, `woql/path.ex`, `woql/literal.ex`). + +### Functions + +| Function | Arity | Description | +|----------|-------|-------------| +| `rdflist_list/2` | 2 | Collect all elements into array | +| `rdflist_peek/2` | 2 | Get first element | +| `rdflist_last/2` | 2 | Get last element | +| `rdflist_nth0/3` | 3 | 0-indexed element access | +| `rdflist_nth1/3` | 3 | 1-indexed element access | +| `rdflist_member/2` | 2 | Iterate elements as bindings | +| `rdflist_length/2` | 2 | List length | +| `rdflist_pop/2` | 2 | Pop first element in-place | +| `rdflist_push/2` | 2 | Push to front in-place | +| `rdflist_append/3` | 3 | Append to end | +| `rdflist_clear/2` | 2 | Delete all cons cells | +| `rdflist_empty/1` | 1 | Create empty list | +| `rdflist_is_empty/1` | 1 | Check if empty | +| `rdflist_slice/4` | 4 | Extract slice [start, end) | +| `rdflist_insert/4` | 4 | Insert at position | +| `rdflist_drop/2` | 2 | Drop element at position | +| `rdflist_swap/3` | 3 | Swap elements at two positions | + +### Testing + +Composition tests verify the composed query structure (not JSON-LD round-trip, +since RDF list ops produce composite `And` queries, not single `@type` ops). + +## Consequences + +- 1 new sub-module, 17 public functions, `localize/1` internal helper. +- ~34 new unit tests (structure + encoding per function). +- `localize/1` is internal; can be exposed publicly later if useful. + +## Alternatives considered + +1. **Port `WOQLLibrary` class pattern** — rejected (Elixir has no classes; + module functions are more idiomatic). +2. **Generate via macros** — rejected (keep v0.3.2 purely functional per + ADR-0008). +3. **Defer to v0.3.3** — rejected (high demand, core RDF functionality). diff --git a/docs/adr/0012-csv-io-query-resource.md b/docs/adr/0012-csv-io-query-resource.md new file mode 100644 index 0000000..f3fec94 --- /dev/null +++ b/docs/adr/0012-csv-io-query-resource.md @@ -0,0 +1,72 @@ +# ADR-0012: CSV / IO and QueryResource encoding + +Date: 2026-06-25 +Status: Accepted (implementation in v0.3.2) + +## Context + +The Python client provides 6 CSV/IO operators: `get`, `put`, `woql_as`, +`file`, `remote`, `post`. These enable reading/writing CSV data from files, +remote URLs, or posted content as part of WOQL queries. + +## Decision + +Implement all 6 operators in the `WOQL` module with encoder/decoder clauses. + +### Operators + +| Function | Arity | JSON-LD `@type` | Description | +|----------|-------|-----------------|-------------| +| `get/2` | 2 | `Get` | Read CSV/columns resource | +| `put/3` | 3 | `Put` | Write array of variables + optional columns | +| `woql_as/1` | 1 | (list of `Column`) | Map column names/indices to variables | +| `file/2` | 2 | `QueryResource` | File source (CSV format) | +| `remote/2` | 2 | `QueryResource` | Remote URL data source | +| `post/2` | 2 | `QueryResource` | File posted as part of request | + +### `woql_as/1` column mapping + +`woql_as/1` accepts a list of `{name_or_index, variable}` tuples and builds +`Column`/`Indicator` JSON-LD objects: + +```json +{ + "@type": "Column", + "indicator": {"@type": "Indicator", "name": "column_name"}, + "variable": "var_name" +} +``` + +For integer indices: `"indicator": {"@type": "Indicator", "index": 0}`. + +### QueryResource encoding + +`file/2`, `remote/2`, `post/2` build `QueryResource` objects with a `source` +field and optional `format` field: + +```json +{ + "@type": "QueryResource", + "source": {"@type": "FileResource", "file_name": "path"}, + "format": {"@type": "Format", "format_type": {"@type": "xsd:string", "@value": "csv"}} +} +``` + +### Options + +- `file/2` opts: `:format` (default `"csv"`) +- `remote/2` opts: `:format` (default `"csv"`) +- `post/2` opts: `:format` (default `"csv"`) + +## Consequences + +- 6 new operators, 6 encoder clauses, 6 decoder clauses. +- ~25 new unit tests. +- `woql_as/1` is a helper, not a standalone WOQL operator — it builds a list + of `Column` objects used by `get/2` and `put/3`. + +## Alternatives considered + +1. **Defer CSV/IO** — rejected (identified as high-priority gap). +2. **Separate `WOQL.CSV` module** — rejected (ops are WOQL operators; keeping + them in `WOQL` matches Python). diff --git a/docs/adr/0013-patch-apply-api.md b/docs/adr/0013-patch-apply-api.md new file mode 100644 index 0000000..6fae496 --- /dev/null +++ b/docs/adr/0013-patch-apply-api.md @@ -0,0 +1,65 @@ +# ADR-0013: Patch class and apply API + +Date: 2026-06-25 +Status: Accepted (implementation in v0.3.2) + +## Context + +The Python client has a `Patch` class for working with TerminusDB's JSON-LD +patch format (diff/patch/apply). The `diff` method is deprecated in favor of +`diff_object`, `diff_version`, `patch`, `patch_resource`, and `apply`. + +The Elixir client has `TerminusDB.Diff.compare/2` but no `Patch` struct and no +patch/apply methods. + +## Decision + +### `TerminusDB.Patch` struct + +New file: `lib/terminus_db/patch.ex` + +```elixir +defstruct [:content] +``` + +Functions: +- `from_json/1` — parse JSON string into `%Patch{content: decoded_map}` +- `to_json/1` — serialize `%Patch{}` to JSON string +- `update/1` — extract `@after` values from `SwapValue` ops (recursive) +- `before/1` — extract `@before` values (recursive) +- `copy/1` — deep copy + +### Diff module extensions + +Extend `TerminusDB.Diff` with: +- `diff_object/2` — diff two concrete objects → `{:ok, %Patch{}}` +- `diff_version/2` — diff two commit/branch versions → `{:ok, %Patch{}}` +- `patch/2` — apply patch to before object (no commit) → `{:ok, after_object}` +- `patch_resource/2` — apply patch to branch resource (commits) — opts: + `:patch`, `:message`, `:author`, `:match_final_state` +- `apply/3` — diff two commits and apply onto branch — opts: + `:before_version`, `:after_version`, `:message`, `:author` + +### Endpoints + +| Method | Endpoint | +|--------|----------| +| `diff_object/2` | `POST /api/diff/{org}/{db}/{repo}/branch/{branch}` | +| `diff_version/2` | `POST /api/diff/{org}/{db}/{repo}/branch/{branch}` | +| `patch/2` | `POST /api/patch` | +| `patch_resource/2` | `POST /api/patch/{org}/{db}/{repo}/branch/{branch}` | +| `apply/3` | `POST /api/apply/{org}/{db}/{repo}/branch/{branch}` | + +## Consequences + +- 1 new struct, 5 new methods. +- `Diff.compare/2` is kept for backward compatibility (equivalent to + `diff_object/2` but returns a raw map instead of `%Patch{}`). +- ~15 new unit tests. + +## Alternatives considered + +1. **Just extend `Diff` without a `Patch` struct** — rejected (the `Patch` + struct provides useful projections (`update`, `before`) that are worth + encapsulating). +2. **Defer to v0.3.3** — rejected (fills a clear gap, small scope). diff --git a/docs/adr/README.md b/docs/adr/README.md index f48994f..bfafe5c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,6 +11,12 @@ Numbered Architecture Decision Records for `terminusdb_ex`, newest last. | [0005](0005-telemetry-strategy.md) | Telemetry events and measurements | Accepted | | [0006](0006-testing-strategy.md) | Layered testing strategy | Accepted | | [0007](0007-streaming-strategy.md) | Streaming via Req `into:` + Enumerable | Accepted | +| [0008](0008-woql-dsl-v0.2.md) | WOQL DSL v0.2 — vocabulary expansion, 4-wrapper value model, dual path DSL | Accepted | +| [0009](0009-graphql-strategy.md) | GraphQL thin wrapper strategy | Accepted | +| [0010](0010-temporal-allen-woql.md) | Temporal / Allen interval algebra WOQL operators | Accepted | +| [0011](0011-rdf-list-library.md) | RDF list library composition | Accepted | +| [0012](0012-csv-io-query-resource.md) | CSV / IO and QueryResource encoding | Accepted | +| [0013](0013-patch-apply-api.md) | Patch class and apply API | Accepted | Format: each ADR follows the Michael Nygard template (Context, Decision, Consequences, Alternatives). diff --git a/guides/csv-import-guide.md b/guides/csv-import-guide.md new file mode 100644 index 0000000..7bfeab8 --- /dev/null +++ b/guides/csv-import-guide.md @@ -0,0 +1,107 @@ +# CSV Import / Export Guide + +TerminusDB provides WOQL CSV/IO operators for reading and writing CSV data from files, remote URLs, or posted content. + +## Reading CSV + +```elixir +import TerminusDB.WOQL + +# Read a CSV file with column mapping +query = + get( + woql_as([{"name", "v:Name"}, {"age", "v:Age"}]), + file("data.csv") + ) + +# Execute to load the data +{:ok, result} = TerminusDB.WOQL.execute(config, query, author: "admin", message: "import CSV") +``` + +### Column mapping with `woql_as` + +```elixir +import TerminusDB.WOQL + +# By column name +cols = woql_as([{"name", "v:Name"}, {"email", "v:Email"}]) + +# By column index (0-based) +cols = woql_as([{0, "v:First"}, {1, "v:Second"}]) + +# Mixed +cols = woql_as([{"name", "v:Name"}, {1, "v:Email"}]) +``` + +### Reading from a remote URL + +```elixir +import TerminusDB.WOQL + +query = + get( + woql_as([{"name", "v:Name"}, {"age", "v:Age"}]), + remote("https://example.com/data.csv") + ) +``` + +### Reading a posted file + +```elixir +import TerminusDB.WOQL + +query = + get( + woql_as([{"name", "v:Name"}]), + post("upload.csv") + ) +``` + +## Writing CSV + +```elixir +import TerminusDB.WOQL + +# Write query results to a CSV file +query = + put( + woql_as([{"name", "v:Name"}, {"age", "v:Age"}]), + and_([ + triple("v:Person", "name", "v:Name"), + triple("v:Person", "age", "v:Age") + ]), + file("output.csv") + ) + +{:ok, result} = TerminusDB.WOQL.execute(config, query, author: "admin", message: "export CSV") +``` + +## Custom formats + +```elixir +import TerminusDB.WOQL + +# JSON format instead of CSV +query = get(woql_as([{"name", "v:Name"}]), file("data.json", format: "json")) +``` + +## Combining CSV import with schema insertion + +```elixir +import TerminusDB.WOQL + +# Read CSV and insert as documents +query = + and_([ + get( + woql_as([{"name", "v:Name"}, {"age", "v:Age"}]), + file("people.csv") + ), + unique("Person", ["v:Name"], "v:Id"), + add_triple("v:Id", "rdf:type", iri("@schema:Person")), + add_triple("v:Id", "name", "v:Name"), + add_triple("v:Id", "age", "v:Age") + ]) + +{:ok, result} = TerminusDB.WOQL.execute(config, query, author: "admin", message: "import people") +``` diff --git a/guides/graphql-guide.md b/guides/graphql-guide.md new file mode 100644 index 0000000..9fd8ac5 --- /dev/null +++ b/guides/graphql-guide.md @@ -0,0 +1,165 @@ +# GraphQL Guide + +TerminusDB auto-generates a GraphQL endpoint from your database's document schema. The `terminusdb_ex` client provides a thin HTTP wrapper for sending GraphQL queries and mutations. + +## Connecting + +```elixir +config = TerminusDB.Config.new(endpoint: "http://localhost:6363") + |> TerminusDB.Config.with_database("mydb") +``` + +## Queries + +```elixir +{:ok, result} = TerminusDB.GraphQL.query(config, "{ Person { name age } }") +# => {:ok, %{data: %{"Person" => [%{"name" => "Alice", "age" => 30}]}, errors: nil}} +``` + +### Filtering + +```elixir +{:ok, result} = TerminusDB.GraphQL.query(config, """ + { + Person(filter: {name: {eq: "Alice"}}) { + name + age + } + } +""") +``` + +### Pagination + +```elixir +{:ok, result} = TerminusDB.GraphQL.query(config, """ + { + Person(limit: 10, offset: 20) { name } + } +""") +``` + +### Ordering + +```elixir +{:ok, result} = TerminusDB.GraphQL.query(config, """ + { + Person(orderBy: {name: ASC}) { name } + } +""") +``` + +### Path queries + +```elixir +{:ok, result} = TerminusDB.GraphQL.query(config, """ + { + Person { + name + _path(friend+: { name: "Bob" }) { name } + } + } +""") +``` + +### Backlinks + +```elixir +{:ok, result} = TerminusDB.GraphQL.query(config, """ + { + Person { + name + _backlink(friend: {}) { name } + } + } +""") +``` + +### Count + +```elixir +{:ok, result} = TerminusDB.GraphQL.query(config, """ + { + Person { _count } + } +""") +``` + +## Mutations + +### Insert documents + +```elixir +{:ok, result} = TerminusDB.GraphQL.mutate(config, """ + mutation { + _insertDocuments(json: "{\\"@type\\":\\"Person\\",\\"name\\":\\"Alice\\"}") + } +""") +``` + +### Replace documents + +```elixir +{:ok, result} = TerminusDB.GraphQL.mutate(config, """ + mutation { + _replaceDocuments(json: "{\\"@id\\":\\"Person/Alice\\",\\"name\\":\\"Alicia\\"}", create: true) + } +""") +``` + +### Delete documents + +```elixir +{:ok, result} = TerminusDB.GraphQL.mutate(config, """ + mutation { + _deleteDocuments(ids: ["Person/Alice"]) + } +""") +``` + +### With commit info + +```elixir +{:ok, result} = TerminusDB.GraphQL.mutate(config, """ + mutation { + _insertDocuments(json: "{\\"@type\\":\\"Person\\",\\"name\\":\\"Bob\\"}") { + _commitInfo { author message } + } + } +""") +``` + +## Introspection + +```elixir +{:ok, schema} = TerminusDB.GraphQL.introspect(config) +# => {:ok, %{"__schema" => %{"types" => [...]}}} +``` + +## Variables + +```elixir +{:ok, result} = TerminusDB.GraphQL.query(config, """ + query($name: String) { + Person(filter: {name: {eq: $name}}) { name } + } +""", %{"name" => "Alice"}) +``` + +## Error handling + +```elixir +case TerminusDB.GraphQL.query(config, "{ Person { name } }") do + {:ok, %{data: data, errors: nil}} -> + # Success + IO.inspect(data) + + {:ok, %{data: nil, errors: errors}} -> + # GraphQL errors + IO.inspect(errors) + + {:error, error} -> + # HTTP/transport error + IO.inspect(error) +end +``` diff --git a/guides/overview.md b/guides/overview.md index bc26888..8b4203f 100644 --- a/guides/overview.md +++ b/guides/overview.md @@ -490,8 +490,8 @@ WOQL.path("v:S", WOQL.Path.path_star(WOQL.Path.path_pred("friend")), "v:O") | Function | WOQL type | Description | | --- | --- | --- | | `read_document/2` | `ReadDocument` | Read document by ID | -| `insert_document/1` | `InsertDocument` | Insert document | -| `update_document/1` | `UpdateDocument` | Update document | +| `insert_document/2` | `InsertDocument` | Insert document (optional identifier) | +| `update_document/2` | `UpdateDocument` | Update document (optional identifier) | | `delete_document/1` | `DeleteDocument` | Delete document | #### Literal helpers diff --git a/guides/rdf-list-guide.md b/guides/rdf-list-guide.md new file mode 100644 index 0000000..6b2c34c --- /dev/null +++ b/guides/rdf-list-guide.md @@ -0,0 +1,122 @@ +# RDF List Library Guide + +TerminusDB provides 17 functions for manipulating RDF `rdf:List` structures via `TerminusDB.WOQL.RDFList`. + +## Overview + +RDF lists are linked lists built from `rdf:List` cons cells. Each cell has: +- `rdf:first` — the element value +- `rdf:rest` — a pointer to the next cell (or `rdf:nil` for the empty list) + +## Reading + +### Get the first element + +```elixir +import TerminusDB.WOQL + +query = and_([ + triple("v:List", "rdf:type", iri("rdf:List")), + TerminusDB.WOQL.RDFList.rdflist_peek("v:List", "v:First") +]) +``` + +### Get the last element + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_last("v:List", "v:Last") +``` + +### Get element at position + +```elixir +# 0-indexed +query = TerminusDB.WOQL.RDFList.rdflist_nth0("v:List", 2, "v:Elem") + +# 1-indexed +query = TerminusDB.WOQL.RDFList.rdflist_nth1("v:List", 3, "v:Elem") +``` + +### Iterate all elements + +```elixir +# Each element as a separate binding +query = TerminusDB.WOQL.RDFList.rdflist_member("v:List", "v:Elem") + +# All elements as an array +query = TerminusDB.WOQL.RDFList.rdflist_list("v:List", "v:Array") +``` + +### Get length + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_length("v:List", "v:Len") +``` + +### Check if empty + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_is_empty("v:List") +``` + +### Create an empty list + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_empty("v:List") +``` + +## Mutation + +### Push (prepend) + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_push("v:List", "v:Value", "v:NewHead") +``` + +### Pop (remove first) + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_pop("v:List", "v:Value") +``` + +### Append + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_append("v:List", "v:Value") +``` + +### Clear + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_clear("v:List", "v:NewList") +``` + +### Insert at position + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_insert("v:List", 1, "v:Value") +``` + +### Drop at position + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_drop("v:List", 1) +``` + +### Swap elements + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_swap("v:List", 0, 2) +``` + +### Slice + +```elixir +query = TerminusDB.WOQL.RDFList.rdflist_slice("v:List", 0, 3, "v:Result") +``` + +## Variable safety + +All `RDFList` functions use an internal `localize` helper that generates +process-unique variable names (e.g., `v:RDFList_head_12345`) to avoid +collisions with your query's variables. diff --git a/guides/temporal-allen-guide.md b/guides/temporal-allen-guide.md new file mode 100644 index 0000000..e03e819 --- /dev/null +++ b/guides/temporal-allen-guide.md @@ -0,0 +1,120 @@ +# Temporal & Allen Interval Algebra Guide + +TerminusDB provides 19 temporal operators for working with time intervals, dates, and Allen's interval algebra. This guide covers the key operations. + +## Intervals + +### Constructing intervals + +```elixir +import TerminusDB.WOQL + +# Construct a half-open interval [start, end) +query = interval("v:Start", "v:End", "v:Interval") + +# With a known duration +query = interval_start_duration("v:Start", "v:Duration", "v:Interval") +query = interval_duration_end("v:Duration", "v:End", "v:Interval") +``` + +## Allen's Interval Algebra + +Allen's interval algebra defines 13 relations between time intervals: + +```elixir +import TerminusDB.WOQL + +# Classify the relationship between two intervals +query = interval_relation("v:Relation", "v:XStart", "v:XEnd", "v:YStart", "v:YEnd") + +# For typed intervals (xdd:dateTimeInterval) +query = interval_relation_typed("v:Relation", "v:X", "v:Y") +``` + +The possible relations are: `before`, `after`, `meets`, `met_by`, `overlaps`, +`overlapped_by`, `during`, `contains`, `starts`, `started_by`, `finishes`, +`finished_by`, `equals`. + +## Date arithmetic + +```elixir +import TerminusDB.WOQL + +# Duration between two dates (end-of-month preserving) +query = date_duration("v:Start", "v:End", "v:Duration") + +# Day after / before +query = day_after("v:Date", "v:NextDay") +query = day_before("v:Date", "v:PrevDay") +``` + +## Calendar operations + +### Weekday + +```elixir +import TerminusDB.WOQL + +# ISO 8601 weekday (Monday=1, Sunday=7) +query = weekday("v:Date", "v:Weekday") + +# US convention (Sunday=1, Saturday=7) +query = weekday_sunday_start("v:Date", "v:Weekday") +``` + +### ISO week + +```elixir +import TerminusDB.WOQL + +# ISO 8601 week-numbering year and week +query = iso_week("v:Date", "v:Year", "v:Week") +``` + +### Month operations + +```elixir +import TerminusDB.WOQL + +# First/last day of a month +query = month_start_date("v:YearMonth", "v:Date") +query = month_end_date("v:YearMonth", "v:Date") + +# Generators: every first/last of month in [start, end) +query = month_start_dates("v:Date", "v:Start", "v:End") +query = month_end_dates("v:Date", "v:Start", "v:End") +``` + +## Range utilities + +```elixir +import TerminusDB.WOQL + +# Test if value is in half-open range [start, end) +query = in_range("v:Value", 10, 100) + +# Generate a sequence +query = sequence("v:Value", 1, 10) # 1..9 +query = sequence("v:Value", 1, 10, 2) # 1, 3, 5, 7, 9 (step=2) +query = sequence("v:Value", 1, 10, 2, 5) # First 5 values: 1, 3, 5, 7, 9 + +# Min/max of a list +query = range_min("v:List", "v:Min") +query = range_max("v:List", "v:Max") +``` + +## Range queries on triples + +```elixir +import TerminusDB.WOQL + +# Find triples with object value in [10, 100) +query = triple_slice("v:Subject", "v:Predicate", "v:Object", 10, 100) + +# Descending order +query = triple_slice_rev("v:Subject", "v:Predicate", "v:Object", 10, 100) + +# Find next/previous value +query = triple_next("v:Subject", "v:Predicate", "v:Object", "v:Next") +query = triple_previous("v:Subject", "v:Predicate", "v:Object", "v:Prev") +``` diff --git a/guides/terminusdb_ex_livebook.livemd b/guides/terminusdb_ex_livebook.livemd index 0bc7a41..dbc465d 100644 --- a/guides/terminusdb_ex_livebook.livemd +++ b/guides/terminusdb_ex_livebook.livemd @@ -1,10 +1,8 @@ - - # terminusdb_ex Livebook Demo ```elixir Mix.install([ - {:terminusdb_client, "~> 0.1.0"} + {:terminusdb_client, "~> 0.3.2"} ]) ``` @@ -33,7 +31,7 @@ end ## 1. Configuration ```elixir -alias TerminusDB.{Config, Database, Document, Schema, Branch, Client, Error} +alias TerminusDB.{Config, Database, Document, Schema, Branch, Client} config = Config.new(endpoint: endpoint) ``` @@ -64,11 +62,16 @@ Config.redact(config) ```elixir # Create a database with a schema graph -{:ok, _} = Database.create(config, "livebook_demo", - label: "Livebook Demo", - comment: "A database for the livebook walkthrough", - schema: true -) +if Database.exists?(config, "livebook_demo") do + {:ok, _} = Database.delete(config, "livebook_demo", force: true) +end + +{:ok, _} = + Database.create(config, "livebook_demo", + label: "Livebook Demo", + comment: "A database for the livebook walkthrough", + schema: true + ) ``` ``` @@ -228,7 +231,7 @@ feature_config = Config.with_branch(config, "feature-x") # Insert on the branch {:ok, _} = Document.insert(feature_config, - %{"@type" => "Person", "name" => "Dave", "age" => 40}, + %{"@type" => "Person", "name" => "Dave", "age" => 40, "email" => "dave@example.com"}, author: "admin", message: "Add Dave on feature branch" ) @@ -289,7 +292,7 @@ Document.stream(config, type: "Person") ```elixir # Now operations emit telemetry events {:ok, _} = Document.insert(config, - %{"@type" => "Person", "name" => "Eve", "age" => 35}, + %{"@type" => "Person", "name" => "Eve", "age" => 35, "email" => "eve@example.com"}, author: "admin", message: "Add Eve" ) @@ -311,11 +314,14 @@ case Database.create(config, "livebook_demo", label: "Duplicate") do {:ok, _} -> IO.puts("Created (unexpected)") - {:error, %Error{reason: :api, api_type: "api:DatabaseAlreadyExists"} = e} -> + {:error, %{reason: :api, api_type: "api:DatabaseAlreadyExists"} = e} -> IO.puts("Expected error: #{Exception.message(e)}") - {:error, %Error{} = e} -> - IO.puts("Other error: #{Exception.message(e)}") + {:error, %{reason: :api} = e} -> + IO.puts("Other API error: #{Exception.message(e)}") + + {:error, e} -> + IO.puts("Other error: #{inspect(e)}") end ``` @@ -325,7 +331,12 @@ end ```elixir # Raising variant -Database.create!(config, "livebook_demo", label: "Duplicate") +try do + Database.create!(config, "livebook_demo", label: "Duplicate") +rescue + e in TerminusDB.Error -> + IO.puts("Raised as expected: #{Exception.message(e)}") +end ``` ``` diff --git a/lib/terminus_db/benchmark.ex b/lib/terminus_db/benchmark.ex new file mode 100644 index 0000000..2b893ee --- /dev/null +++ b/lib/terminus_db/benchmark.ex @@ -0,0 +1,135 @@ +defmodule TerminusDB.Benchmark do + @moduledoc """ + Benchmarking helpers for `terminusdb_ex`. + + Provides utilities for generating test data, seeding databases, and + creating representative WOQL queries for benchmarking. + + ## Quick start + + # Generate test data + people = TerminusDB.Benchmark.generate_data(1000) + + # Seed a database + config = TerminusDB.Benchmark.seed_database(config, 500) + + # Get representative queries + queries = TerminusDB.Benchmark.generate_queries(100) + + """ + + alias TerminusDB.{Config, Database, Document, WOQL} + + @doc """ + Generates `count` Person documents with random names and ages. + """ + @spec generate_data(pos_integer()) :: [map()] + def generate_data(count) do + Enum.map(1..count, fn i -> + %{ + "@type" => "Person", + "name" => "Person_#{i}", + "age" => :rand.uniform(100) + } + end) + end + + @doc """ + Creates a database with a Person schema and inserts `count` documents. + + Returns `{config, db_name}` so callers can clean up the database after + benchmarking. + """ + @spec seed_database(Config.t(), pos_integer()) :: {Config.t(), String.t()} + def seed_database(config, count) do + db_name = "bench_#{:erlang.unique_integer([:positive])}" + Database.create!(config, db_name, label: "Benchmark", schema: true) + scoped = Config.with_database(config, db_name) + + Document.insert!( + scoped, + %{ + "@type" => "Class", + "@id" => "Person", + "@key" => %{"@type" => "Lexical", "@fields" => ["name"]}, + "name" => "xsd:string", + "age" => "xsd:integer" + }, + author: "admin", + message: "add schema", + graph_type: :schema + ) + + data = generate_data(count) + + Document.insert!( + scoped, + data, + author: "admin", + message: "seed #{count} documents" + ) + + {scoped, db_name} + end + + @doc """ + Returns a list of representative WOQL queries for benchmarking. + """ + @spec generate_queries(pos_integer()) :: [WOQL.t()] + def generate_queries(count) do + [ + # Simple triple pattern + WOQL.triple("v:Person", "name", "v:Name"), + + # And with multiple patterns + WOQL.and_([ + WOQL.triple("v:Person", "name", "v:Name"), + WOQL.triple("v:Person", "age", "v:Age"), + WOQL.triple("v:Person", "rdf:type", WOQL.iri("@schema:Person")) + ]), + + # Select with limit + WOQL.select(["v:Name"], WOQL.limit(count, WOQL.triple("v:P", "name", "v:Name"))), + + # Order by + WOQL.select( + ["v:Name", "v:Age"], + WOQL.order_by( + [{"v:Age", :asc}], + WOQL.and_([ + WOQL.triple("v:P", "name", "v:Name"), + WOQL.triple("v:P", "age", "v:Age") + ]) + ) + ), + + # Path query + WOQL.path("v:S", "friend+", "v:O"), + + # Arithmetic + WOQL.eval(WOQL.plus(["v:Age", 1]), "v:Result"), + + # String concat + WOQL.concat(["v:Name", "_suffix"], "v:Result") + ] + end + + @doc """ + Returns path patterns of varying complexity for benchmarking. + """ + @spec generate_path_patterns() :: [String.t()] + def generate_path_patterns do + [ + "friend", + "friend*", + "friend+", + " config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success", "api:commit" => "abc123"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Branch.squash(config, author: "admin", message: "squash") + iex> resp["api:commit"] + "abc123" + + """ + @spec squash(Config.t(), [branch_opt()]) :: {:ok, map()} | {:error, Error.t()} + def squash(config, opts \\ []) do + path = squash_path(config, opts) + + commit_info = + %{} + |> Params.maybe_put("author", opts[:author]) + |> Params.maybe_put("message", opts[:message]) + + Client.request(config, :post, path, json: %{"commit_info" => commit_info}, area: :branch) + end + + @doc """ + Squashes the branch HEAD, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success", "api:commit" => "abc"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Branch.squash!(config, author: "admin", message: "squash") + %{"api:commit" => "abc"} + + """ + @spec squash!(Config.t(), [branch_opt()]) :: map() + def squash!(config, opts \\ []) do + case squash(config, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @doc """ + Hard-resets the branch HEAD to a specific commit. + + ## Options + + - `:organization` — overrides `config.organization`. + - `:repo` — overrides `config.repo`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Branch.reset(config, "admin/mydb/local/commit/abc123") + iex> resp["api:status"] + "api:success" + + """ + @spec reset(Config.t(), String.t(), [branch_opt()]) :: {:ok, map()} | {:error, Error.t()} + def reset(config, commit_descriptor, opts \\ []) do + path = reset_path(config, opts) + + Client.request(config, :post, path, + json: %{"commit_descriptor" => commit_descriptor}, + area: :branch + ) + end + + @doc """ + Hard-resets the branch HEAD, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Branch.reset!(config, "admin/mydb/local/commit/abc") + %{"api:status" => "api:success"} + + """ + @spec reset!(Config.t(), String.t(), [branch_opt()]) :: map() + def reset!(config, commit_descriptor, opts \\ []) do + case reset(config, commit_descriptor, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + defp squash_path(config, opts) do + org = opts[:organization] || config.organization + db = config.database || raise Error, reason: :http, message: "no database scoped in config" + repo = opts[:repo] || config.repo + branch = config.branch + "squash/#{org}/#{db}/#{repo}/branch/#{branch}" + end + + defp reset_path(config, opts) do + org = opts[:organization] || config.organization + db = config.database || raise Error, reason: :http, message: "no database scoped in config" + repo = opts[:repo] || config.repo + branch = config.branch + "reset/#{org}/#{db}/#{repo}/branch/#{branch}" + end + @doc """ Returns `true` if the branch `branch_name` exists, `false` otherwise. diff --git a/lib/terminus_db/commit.ex b/lib/terminus_db/commit.ex index fb0175c..5a66b1f 100644 --- a/lib/terminus_db/commit.ex +++ b/lib/terminus_db/commit.ex @@ -38,6 +38,14 @@ defmodule TerminusDB.Commit do | {:limit, pos_integer()} | {:count, pos_integer()} + @type history_opt :: + {:id, String.t()} + | {:start, non_neg_integer()} + | {:count, pos_integer()} + | {:created, boolean()} + | {:updated, boolean()} + | {:organization, String.t()} + defp commit_path(config, opts, resource) do org = opts[:organization] || config.organization db = config.database || raise Error, reason: :http, message: "no database scoped in config" @@ -219,4 +227,72 @@ defmodule TerminusDB.Commit do {:error, error} -> raise error end end + + @doc """ + Retrieves the commit history for a specific document. + + Returns the history of changes made to a document, ordered backwards in + time from the most recent change. + + ## Options + + - `:id` (required) - the document ID (IRI) to retrieve history for. + - `:start` - starting index for pagination (default `0`). + - `:count` - maximum number of entries (default `10`). + - `:created` - if `true`, return only the creation time. + - `:updated` - if `true`, return only the last update time. + - `:organization` - overrides `config.organization`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"author" => "admin", "identifier" => "abc", "message" => "Created"}])} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, history} = TerminusDB.Commit.document_history(config, id: "Person/Alice", count: 5) + iex> length(history) + 1 + + """ + @spec document_history(Config.t(), [history_opt()]) :: + {:ok, [map()]} | {:error, Error.t()} + def document_history(config, opts \\ []) do + org = opts[:organization] || config.organization + db = config.database || raise Error, reason: :http, message: "no database scoped in config" + + params = + [ + id: Keyword.fetch!(opts, :id), + start: opts[:start] || 0, + count: opts[:count] || 10 + ] + |> maybe_add_flag(:created, opts[:created]) + |> maybe_add_flag(:updated, opts[:updated]) + + Client.request(config, :get, "history/#{org}/#{db}", params: params, area: :commit) + end + + @doc """ + Retrieves document commit history, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"author" => "admin"}])} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Commit.document_history!(config, id: "Person/Alice") + [%{"author" => "admin"}] + + """ + @spec document_history!(Config.t(), [history_opt()]) :: [map()] + def document_history!(config, opts \\ []) do + case document_history(config, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + defp maybe_add_flag(params, _key, nil), do: params + defp maybe_add_flag(params, key, value), do: Keyword.put(params, key, value) end diff --git a/lib/terminus_db/database.ex b/lib/terminus_db/database.ex index 48fdaab..d5f3e60 100644 --- a/lib/terminus_db/database.ex +++ b/lib/terminus_db/database.ex @@ -338,5 +338,46 @@ defmodule TerminusDB.Database do end end + @doc """ + Optimizes a resource path (branch, `_meta`, or `_commits` graph). + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Database.optimize(config, "admin/mydb/local/branch/main") + iex> resp["api:status"] + "api:success" + + """ + @spec optimize(TerminusDB.Config.t(), String.t()) :: + {:ok, map()} | {:error, TerminusDB.Error.t()} + def optimize(config, path) do + TerminusDB.Client.request(config, :post, "optimize/#{path}", area: :database) + end + + @doc """ + Optimizes a resource path, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) + iex> TerminusDB.Database.optimize!(config, "_system") + %{"api:status" => "api:success"} + + """ + @spec optimize!(TerminusDB.Config.t(), String.t()) :: map() + def optimize!(config, path) do + case optimize(config, path) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + # Helpers ------------------------------------------------------------------- end diff --git a/lib/terminus_db/diff.ex b/lib/terminus_db/diff.ex index 37ea2bd..98261a4 100644 --- a/lib/terminus_db/diff.ex +++ b/lib/terminus_db/diff.ex @@ -1,9 +1,11 @@ defmodule TerminusDB.Diff do + import Kernel, except: [apply: 2] + @moduledoc """ - Document diff API for TerminusDB. + Document diff and patch API for TerminusDB. - Wraps the `/api/diff` endpoint to compare two document states and return a - structured JSON patch describing the differences. + Wraps the `/api/diff`, `/api/patch`, and `/api/apply` endpoints to compare, + patch, and apply document changes. Diffs can be computed between: - Two document values (`before` and `after` maps). @@ -17,14 +19,19 @@ defmodule TerminusDB.Diff do |> TerminusDB.Config.with_database("mydb") # Diff two document values - {:ok, patch} = TerminusDB.Diff.compare(config, + {:ok, patch} = TerminusDB.Diff.diff_object(config, before: %{"@id" => "Person/Alice", "name" => "Alice"}, after: %{"@id" => "Person/Alice", "name" => "Alicia"} ) + # Apply a patch to a branch + {:ok, _} = TerminusDB.Diff.patch_resource(config, + patch: patch, message: "update name", author: "admin" + ) + """ - alias TerminusDB.{Client, Config, Error} + alias TerminusDB.{Client, Config, Error, Patch} alias TerminusDB.Client.Params @type compare_opt :: @@ -120,4 +127,385 @@ defmodule TerminusDB.Diff do {:error, error} -> raise error end end + + @type diff_object_opt :: + {:before, map()} + | {:after, map()} + | {:keep, map()} + | {:organization, String.t()} + | {:repo, String.t()} + + @doc """ + Diffs two concrete document objects and returns a `TerminusDB.Patch` struct. + + ## Options + + - `:before` (required) - the "before" document map. + - `:after` (required) - the "after" document map. + - `:keep` - a map of fields to preserve in the diff. + - `:organization` - overrides `config.organization`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> + ...> {req, Req.Response.new(status: 200, body: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}})} + ...> end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, patch} = TerminusDB.Diff.diff_object(config, + ...> before: %{"@id" => "Person/1", "name" => "old"}, + ...> after: %{"@id" => "Person/1", "name" => "new"} + ...> ) + iex> patch.content["name"]["@after"] + "new" + + """ + @spec diff_object(Config.t(), [diff_object_opt()]) :: + {:ok, Patch.t()} | {:error, Error.t()} + def diff_object(config, opts \\ []) do + path = diff_resource_path(config, opts) + + before_value = Keyword.fetch!(opts, :before) + after_value = Keyword.fetch!(opts, :after) + + body = + Params.maybe_put(%{"before" => before_value, "after" => after_value}, "keep", opts[:keep]) + + case Client.request(config, :post, path, json: body, area: :diff) do + {:ok, patch_content} -> {:ok, %Patch{content: patch_content}} + {:error, _} = error -> error + end + end + + @doc """ + Diffs two concrete document objects, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> + ...> {req, Req.Response.new(status: 200, body: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}})} + ...> end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> patch = TerminusDB.Diff.diff_object!(config, + ...> before: %{"name" => "old"}, + ...> after: %{"name" => "new"} + ...> ) + iex> patch.content["name"]["@after"] + "new" + + """ + @spec diff_object!(Config.t(), [diff_object_opt()]) :: Patch.t() + def diff_object!(config, opts \\ []) do + case diff_object(config, opts) do + {:ok, patch} -> patch + {:error, error} -> raise error + end + end + + @type diff_version_opt :: + {:before_version, String.t()} + | {:after_version, String.t()} + | {:organization, String.t()} + | {:repo, String.t()} + + @doc """ + Diffs two commit/branch versions and returns a `TerminusDB.Patch` struct. + + ## Options + + - `:before_version` (required) - the before commit/branch descriptor. + - `:after_version` (required) - the after commit/branch descriptor. + - `:organization` - overrides `config.organization`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, patch} = TerminusDB.Diff.diff_version(config, + ...> before_version: "admin/mydb/local/branch/main", + ...> after_version: "admin/mydb/local/branch/feature" + ...> ) + iex> patch.content + %{} + + """ + @spec diff_version(Config.t(), [diff_version_opt()]) :: + {:ok, Patch.t()} | {:error, Error.t()} + def diff_version(config, opts \\ []) do + path = diff_resource_path(config, opts) + + body = %{ + "before_data_version" => Keyword.fetch!(opts, :before_version), + "after_data_version" => Keyword.fetch!(opts, :after_version) + } + + case Client.request(config, :post, path, json: body, area: :diff) do + {:ok, patch_content} -> {:ok, %Patch{content: patch_content}} + {:error, _} = error -> error + end + end + + @doc """ + Diffs two versions, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> patch = TerminusDB.Diff.diff_version!(config, + ...> before_version: "admin/mydb/local/branch/main", + ...> after_version: "admin/mydb/local/branch/feature" + ...> ) + iex> patch.content + %{} + + """ + @spec diff_version!(Config.t(), [diff_version_opt()]) :: Patch.t() + def diff_version!(config, opts \\ []) do + case diff_version(config, opts) do + {:ok, patch} -> patch + {:error, error} -> raise error + end + end + + @type patch_opt :: + {:organization, String.t()} + + @doc """ + Applies a patch to a "before" object and returns the "after" object (no + commit). + + ## Options + + - `:organization` - overrides `config.organization`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@id" => "Person/1", "name" => "new"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, after_obj} = TerminusDB.Diff.patch(config, + ...> before: %{"@id" => "Person/1", "name" => "old"}, + ...> patch: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}} + ...> ) + iex> after_obj["name"] + "new" + + """ + @spec patch(Config.t(), [patch_opt() | {:before, map()} | {:patch, map()}]) :: + {:ok, map()} | {:error, Error.t()} + def patch(config, opts \\ []) do + before_value = Keyword.fetch!(opts, :before) + patch_value = Keyword.fetch!(opts, :patch) + + body = %{"before" => before_value, "patch" => patch_value} + + Client.request(config, :post, "patch", json: body, area: :diff) + end + + @doc """ + Applies a patch, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"name" => "new"})} end + ...> ) + iex> TerminusDB.Diff.patch!(config, + ...> before: %{"name" => "old"}, + ...> patch: %{"name" => %{"@op" => "SwapValue", "@after" => "new"}} + ...> ) + %{"name" => "new"} + + """ + @spec patch!(Config.t(), [patch_opt() | {:before, map()} | {:patch, map()}]) :: map() + def patch!(config, opts \\ []) do + case patch(config, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @type patch_resource_opt :: + {:patch, map()} + | {:message, String.t()} + | {:author, String.t()} + | {:match_final_state, boolean()} + | {:organization, String.t()} + | {:repo, String.t()} + + @doc """ + Applies a patch to a branch resource (commits the change). + + ## Options + + - `:patch` (required) - the patch content. + - `:message` - commit message. + - `:author` - commit author. + - `:match_final_state` - boolean. + - `:organization` - overrides `config.organization`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Diff.patch_resource(config, + ...> patch: %{"name" => %{"@op" => "SwapValue", "@after" => "new"}}, + ...> author: "admin", message: "update" + ...> ) + iex> resp["api:status"] + "api:success" + + """ + @spec patch_resource(Config.t(), [patch_resource_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def patch_resource(config, opts \\ []) do + path = patch_resource_path(config, opts) + patch_value = Keyword.fetch!(opts, :patch) + + body = + %{"patch" => patch_value} + |> Params.maybe_put("message", opts[:message]) + |> Params.maybe_put("author", opts[:author]) + |> Params.maybe_put("match_final_state", opts[:match_final_state]) + + Client.request(config, :post, path, json: body, area: :diff) + end + + @doc """ + Applies a patch to a branch resource, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Diff.patch_resource!(config, patch: %{}) + %{"api:status" => "api:success"} + + """ + @spec patch_resource!(Config.t(), [patch_resource_opt()]) :: map() + def patch_resource!(config, opts \\ []) do + case patch_resource(config, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @type apply_opt :: + {:before_version, String.t()} + | {:after_version, String.t()} + | {:message, String.t()} + | {:author, String.t()} + | {:organization, String.t()} + | {:repo, String.t()} + + @doc """ + Diffs two commits and applies the changes onto a branch. + + ## Options + + - `:before_version` (required) - the before commit descriptor. + - `:after_version` (required) - the after commit descriptor. + - `:message` - commit message. + - `:author` - commit author. + - `:organization` - overrides `config.organization`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Diff.apply(config, + ...> before_version: "admin/mydb/local/commit/abc", + ...> after_version: "admin/mydb/local/commit/def", + ...> author: "admin", message: "apply" + ...> ) + iex> resp["api:status"] + "api:success" + + """ + @spec apply(Config.t(), [apply_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def apply(config, opts \\ []) do + path = apply_path(config, opts) + + commit_info = + %{} + |> Params.maybe_put("author", opts[:author]) + |> Params.maybe_put("message", opts[:message]) + + body = + Params.maybe_put( + %{ + "before_commit" => Keyword.fetch!(opts, :before_version), + "after_commit" => Keyword.fetch!(opts, :after_version) + }, + "commit_info", + commit_info + ) + + Client.request(config, :post, path, json: body, area: :diff) + end + + @doc """ + Applies a diff to a branch, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Diff.apply!(config, + ...> before_version: "admin/mydb/local/commit/abc", + ...> after_version: "admin/mydb/local/commit/def" + ...> ) + %{"api:status" => "api:success"} + + """ + @spec apply!(Config.t(), [apply_opt()]) :: map() + def apply!(config, opts \\ []) do + case apply(config, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + defp diff_resource_path(config, opts) do + org = opts[:organization] || config.organization + db = config.database || raise Error, reason: :http, message: "no database scoped in config" + repo = opts[:repo] || config.repo + branch = config.branch + "diff/#{org}/#{db}/#{repo}/branch/#{branch}" + end + + defp patch_resource_path(config, opts) do + org = opts[:organization] || config.organization + db = config.database || raise Error, reason: :http, message: "no database scoped in config" + repo = opts[:repo] || config.repo + branch = config.branch + "patch/#{org}/#{db}/#{repo}/branch/#{branch}" + end + + defp apply_path(config, opts) do + org = opts[:organization] || config.organization + db = config.database || raise Error, reason: :http, message: "no database scoped in config" + repo = opts[:repo] || config.repo + branch = config.branch + "apply/#{org}/#{db}/#{repo}/branch/#{branch}" + end end diff --git a/lib/terminus_db/graphql.ex b/lib/terminus_db/graphql.ex new file mode 100644 index 0000000..34f218d --- /dev/null +++ b/lib/terminus_db/graphql.ex @@ -0,0 +1,186 @@ +defmodule TerminusDB.GraphQL do + @moduledoc """ + GraphQL API for TerminusDB. + + Wraps the `/api/graphql/{org}/{db}` endpoint. TerminusDB auto-generates a + GraphQL schema from the database's document schema, supporting queries + (filter, limit, offset, orderBy, backlinks, path queries, count) and + mutations (insert/replace/delete documents). + + This is a **thin HTTP wrapper** — users write raw GraphQL query strings. + A programmatic query builder DSL is planned for a future release. + + ## Quick start + + config = + TerminusDB.Config.new(endpoint: "http://localhost:6363") + |> TerminusDB.Config.with_database("mydb") + + # Query + {:ok, result} = TerminusDB.GraphQL.query(config, "{ Person { name age } }") + + # Mutation + {:ok, result} = TerminusDB.GraphQL.mutate(config, ~s''' + mutation { + _insertDocuments(json: "{\\"@type\\":\\"Person\\",\\"name\\":\\"Alice\\"}") + } + ''') + + # Introspect schema + {:ok, schema} = TerminusDB.GraphQL.introspect(config) + + ## Response format + + - `{:ok, %{data: data, errors: nil}}` — successful query with no errors. + - `{:ok, %{data: data, errors: errors}}` — partial success with errors. + - `{:ok, %{data: nil, errors: errors}}` — query failed. + - `{:error, %Error{}}` — HTTP/transport error. + + """ + + alias TerminusDB.{Client, Config, Error} + alias TerminusDB.Client.Params + + @type graphql_opt :: {:organization, String.t()} + + @type result :: {:ok, %{data: term(), errors: [map()] | nil}} | {:error, Error.t()} + + @doc """ + Executes a GraphQL query against the database. + + `query_string` is a raw GraphQL query string. `variables` is an optional + map of variable values for parameterized queries. + + ## Options + + - `:organization` — overrides `config.organization`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"data" => %{"Person" => [%{"name" => "Alice"}]})})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, result} = TerminusDB.GraphQL.query(config, "{ Person { name } }") + iex> result.data["Person"] + [%{"name" => "Alice"}] + + """ + @spec query(Config.t(), String.t(), [graphql_opt()] | map()) :: result() + def query(config, query_string, opts \\ []) + + def query(config, query_string, opts) when is_list(opts) do + variables = Keyword.get(opts, :variables) + do_query(config, query_string, variables, opts) + end + + def query(config, query_string, variables) when is_map(variables) do + do_query(config, query_string, variables, []) + end + + defp do_query(config, query_string, variables, opts) do + org = opts[:organization] || config.organization + + case config.database do + nil -> + {:error, %Error{reason: :config, message: "no database scoped in config"}} + + db -> + path = "graphql/#{org}/#{db}" + body = Params.maybe_put(%{"query" => query_string}, "variables", variables) + + case Client.request(config, :post, path, json: body, area: :graphql) do + {:ok, resp} -> + {:ok, %{data: resp["data"], errors: resp["errors"]}} + + {:error, _} = error -> + error + end + end + end + + @doc """ + Executes a GraphQL mutation against the database. + + Same as `query/3` but conventionally used for mutations. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"data" => %{"_insertDocuments" => ["Person/Alice"]})})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, result} = TerminusDB.GraphQL.mutate(config, ~s(mutation { _insertDocuments(json: "{\\"@type\\":\\"Person\\",\\"name\\":\\"Alice\\"}") })) + iex> result.data["_insertDocuments"] + ["Person/Alice"] + + """ + @spec mutate(Config.t(), String.t(), [graphql_opt()] | map()) :: result() + def mutate(config, query_string, opts \\ []) + + def mutate(config, query_string, opts) when is_list(opts) do + variables = Keyword.get(opts, :variables) + do_query(config, query_string, variables, opts) + end + + def mutate(config, query_string, variables) when is_map(variables) do + do_query(config, query_string, variables, []) + end + + @doc """ + Introspects the GraphQL schema of the database. + + Sends a `__schema` introspection query and returns the raw schema map. + + ## Options + + - `:organization` — overrides `config.organization`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"data" => %{"__schema" => %{"types" => [%{"name" => "Person"}]})})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, schema} = TerminusDB.GraphQL.introspect(config) + iex> schema["__schema"]["types"] + [%{"name" => "Person"}] + + """ + @spec introspect(Config.t(), [graphql_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def introspect(config, opts \\ []) do + introspection_query = """ + { + __schema { + types { + name + kind + description + fields { + name + type { + name + kind + ofType { name kind } + } + } + inputFields { + name + type { + name + kind + ofType { name kind } + } + } + } + } + } + """ + + case do_query(config, introspection_query, nil, opts) do + {:ok, %{data: data}} -> {:ok, data} + {:error, _} = error -> error + end + end +end diff --git a/lib/terminus_db/patch.ex b/lib/terminus_db/patch.ex new file mode 100644 index 0000000..ac8a1a6 --- /dev/null +++ b/lib/terminus_db/patch.ex @@ -0,0 +1,197 @@ +defmodule TerminusDB.Patch do + @moduledoc """ + A JSON-LD patch container for TerminusDB document diffs. + + A `Patch` holds the raw JSON-LD patch content produced by `TerminusDB.Diff` + operations. It provides convenience projections for extracting the "before" + and "after" (update) states from `SwapValue` operations. + + ## Quick start + + {:ok, patch} = TerminusDB.Diff.diff_object(config, + before: %{"@id" => "Person/1", "name" => "old"}, + after: %{"@id" => "Person/1", "name" => "new"} + ) + + patch.update # => %{"name" => "new"} + patch.before # => %{"name" => "old"} + + """ + + @enforce_keys [:content] + defstruct [:content] + + @type t :: %__MODULE__{content: map() | [map()]} + + @doc """ + Parses a JSON string into a `%Patch{}`. + + ## Examples + + iex> {:ok, patch} = TerminusDB.Patch.from_json(~s({"name": {"@op": "SwapValue", "@before": "old", "@after": "new"}})) + iex> patch.content["name"]["@after"] + "new" + + """ + @spec from_json(String.t()) :: {:ok, t()} | {:error, term()} + def from_json(json_string) do + case Jason.decode(json_string) do + {:ok, content} when is_map(content) -> {:ok, %__MODULE__{content: content}} + {:ok, content} when is_list(content) -> {:ok, %__MODULE__{content: content}} + {:error, _} = error -> error + end + end + + @doc """ + Parses a JSON string into a `%Patch{}`, or raises. + + ## Examples + + iex> patch = TerminusDB.Patch.from_json!(~s({"name": {"@op": "SwapValue", "@before": "old", "@after": "new"}})) + iex> patch.content["name"]["@before"] + "old" + + """ + @spec from_json!(String.t()) :: t() + def from_json!(json_string) do + case from_json(json_string) do + {:ok, patch} -> patch + {:error, error} -> raise ArgumentError, "invalid JSON: #{inspect(error)}" + end + end + + @doc """ + Serializes a `%Patch{}` to a JSON string. + + ## Examples + + iex> patch = %TerminusDB.Patch{content: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}}} + iex> json = TerminusDB.Patch.to_json(patch) + iex> is_binary(json) + true + + """ + @spec to_json(t()) :: String.t() + def to_json(%__MODULE__{content: content}) do + Jason.encode!(content) + end + + @doc """ + Extracts the "after" (updated) values from `SwapValue` operations, + recursively. + + Only fields containing `SwapValue` operations are included in the result. + Non-`SwapValue` fields are omitted. This is intentionally asymmetric with + `before/1`, which includes all fields (preserving non-`SwapValue` values) + to allow full reconstruction of the "before" state. + + ## Examples + + iex> patch = %TerminusDB.Patch{content: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}}} + iex> TerminusDB.Patch.update(patch) + %{"name" => "new"} + + """ + @spec update(t()) :: map() + def update(%__MODULE__{content: content}) do + extract_after(content) + end + + @doc """ + Extracts the "before" values from `SwapValue` operations, recursively. + + Includes all fields from the patch content, preserving non-`SwapValue` + values as-is. This is intentionally asymmetric with `update/1`, which + only includes `SwapValue` fields — `before/1` aims to reconstruct the + full "before" state while `update/1` shows only what changed. + + ## Examples + + iex> patch = %TerminusDB.Patch{content: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}}} + iex> TerminusDB.Patch.before(patch) + %{"name" => "old"} + + """ + @spec before(t()) :: map() + def before(%__MODULE__{content: content}) do + extract_before(content) + end + + @doc """ + Creates a deep copy of the patch. + + ## Examples + + iex> patch = %TerminusDB.Patch{content: %{"name" => "value"}} + iex> copy = TerminusDB.Patch.copy(patch) + iex> copy == patch + true + + """ + @spec copy(t()) :: t() + def copy(%__MODULE__{content: content}) do + %__MODULE__{content: deep_copy(content)} + end + + defp extract_after(item) when is_map(item) do + case item do + %{"@op" => "SwapValue", "@after" => after_val} -> + after_val + + _ -> + Enum.reduce(item, %{}, fn {key, val}, acc -> + case val do + %{"@op" => "SwapValue", "@after" => after_val} -> + Map.put(acc, key, after_val) + + v when is_map(v) -> + extracted = extract_after(v) + + if map_size(extracted) > 0 do + Map.put(acc, key, extracted) + else + acc + end + + _ -> + acc + end + end) + end + end + + defp extract_after(_), do: %{} + + defp extract_before(item) when is_map(item) do + Enum.reduce(item, %{}, fn {key, val}, acc -> + case val do + %{"@op" => "SwapValue", "@before" => before_val} -> + Map.put(acc, key, before_val) + + v when is_map(v) -> + extracted = extract_before(v) + + if map_size(extracted) > 0 do + Map.put(acc, key, extracted) + else + acc + end + + v -> + Map.put(acc, key, v) + end + end) + end + + defp extract_before(_), do: %{} + + defp deep_copy(term) when is_map(term) do + Map.new(term, fn {k, v} -> {k, deep_copy(v)} end) + end + + defp deep_copy(term) when is_list(term) do + Enum.map(term, &deep_copy/1) + end + + defp deep_copy(term), do: term +end diff --git a/lib/terminus_db/prefix.ex b/lib/terminus_db/prefix.ex new file mode 100644 index 0000000..c2c4e13 --- /dev/null +++ b/lib/terminus_db/prefix.ex @@ -0,0 +1,320 @@ +defmodule TerminusDB.Prefix do + @moduledoc """ + Prefix management API for TerminusDB. + + Wraps the `/api/prefix/{path}` and `/api/prefixes/{path}` endpoints for + managing custom prefix mappings in a database. + + All functions require a `TerminusDB.Config` scoped to a database. + + ## Quick start + + config = + TerminusDB.Config.new(endpoint: "http://localhost:6363") + |> TerminusDB.Config.with_database("mydb") + + {:ok, uri} = TerminusDB.Prefix.get(config, "ex") + {:ok, _} = TerminusDB.Prefix.add(config, "ex", "http://example.org/") + {:ok, _} = TerminusDB.Prefix.update(config, "ex", "http://example.com/") + {:ok, _} = TerminusDB.Prefix.delete(config, "ex") + + """ + + alias TerminusDB.{Client, Config, Error} + + @type prefix_opt :: {:organization, String.t()} | {:repo, String.t()} + + defp prefix_base_path(config, opts) do + org = opts[:organization] || config.organization + + case config.database do + nil -> + {:error, %Error{reason: :config, message: "no database scoped in config"}} + + db -> + repo = opts[:repo] || config.repo + branch = config.branch + {:ok, "prefix/#{org}/#{db}/#{repo}/branch/#{branch}"} + end + end + + defp prefixes_path(config, opts) do + org = opts[:organization] || config.organization + + case config.database do + nil -> {:error, %Error{reason: :config, message: "no database scoped in config"}} + db -> {:ok, "prefixes/#{org}/#{db}"} + end + end + + @doc """ + Gets a single prefix IRI by name. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:prefix_uri" => "http://example.org/"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, uri} = TerminusDB.Prefix.get(config, "ex") + iex> uri + "http://example.org/" + + """ + @spec get(Config.t(), String.t(), [prefix_opt()]) :: {:ok, String.t()} | {:error, Error.t()} + def get(config, prefix_name, opts \\ []) do + with {:ok, base} <- prefix_base_path(config, opts) do + path = "#{base}/#{prefix_name}" + + case Client.request(config, :get, path, area: :prefix) do + {:ok, %{"api:prefix_uri" => uri}} -> {:ok, uri} + {:ok, body} -> {:ok, body} + {:error, _} = error -> error + end + end + end + + @doc """ + Gets a single prefix IRI by name, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:prefix_uri" => "http://example.org/"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Prefix.get!(config, "ex") + "http://example.org/" + + """ + @spec get!(Config.t(), String.t(), [prefix_opt()]) :: String.t() + def get!(config, prefix_name, opts \\ []) do + case get(config, prefix_name, opts) do + {:ok, uri} -> uri + {:error, error} -> raise error + end + end + + @doc """ + Adds a new prefix mapping. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Prefix.add(config, "ex", "http://example.org/") + iex> resp["api:status"] + "api:success" + + """ + @spec add(Config.t(), String.t(), String.t(), [prefix_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def add(config, prefix_name, uri, opts \\ []) do + with {:ok, base} <- prefix_base_path(config, opts) do + path = "#{base}/#{prefix_name}" + Client.request(config, :post, path, json: %{"uri" => uri}, area: :prefix) + end + end + + @doc """ + Adds a new prefix mapping, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Prefix.add!(config, "ex", "http://example.org/") + %{"api:status" => "api:success"} + + """ + @spec add!(Config.t(), String.t(), String.t(), [prefix_opt()]) :: map() + def add!(config, prefix_name, uri, opts \\ []) do + case add(config, prefix_name, uri, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @doc """ + Updates an existing prefix mapping. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Prefix.update(config, "ex", "http://example.com/") + iex> resp["api:status"] + "api:success" + + """ + @spec update(Config.t(), String.t(), String.t(), [prefix_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def update(config, prefix_name, uri, opts \\ []) do + with {:ok, base} <- prefix_base_path(config, opts) do + path = "#{base}/#{prefix_name}" + Client.request(config, :put, path, json: %{"uri" => uri}, area: :prefix) + end + end + + @doc """ + Updates an existing prefix mapping, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Prefix.update!(config, "ex", "http://example.com/") + %{"api:status" => "api:success"} + + """ + @spec update!(Config.t(), String.t(), String.t(), [prefix_opt()]) :: map() + def update!(config, prefix_name, uri, opts \\ []) do + case update(config, prefix_name, uri, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @doc """ + Creates or updates a prefix mapping (upsert). + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Prefix.upsert(config, "ex", "http://example.org/") + iex> resp["api:status"] + "api:success" + + """ + @spec upsert(Config.t(), String.t(), String.t(), [prefix_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def upsert(config, prefix_name, uri, opts \\ []) do + with {:ok, base} <- prefix_base_path(config, opts) do + path = "#{base}/#{prefix_name}" + + Client.request(config, :put, path, + json: %{"uri" => uri}, + params: [create: true], + area: :prefix + ) + end + end + + @doc """ + Creates or updates a prefix mapping (upsert), or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Prefix.upsert!(config, "ex", "http://example.org/") + %{"api:status" => "api:success"} + + """ + @spec upsert!(Config.t(), String.t(), String.t(), [prefix_opt()]) :: map() + def upsert!(config, prefix_name, uri, opts \\ []) do + case upsert(config, prefix_name, uri, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @doc """ + Deletes a prefix mapping. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Prefix.delete(config, "ex") + iex> resp["api:status"] + "api:success" + + """ + @spec delete(Config.t(), String.t(), [prefix_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def delete(config, prefix_name, opts \\ []) do + with {:ok, base} <- prefix_base_path(config, opts) do + path = "#{base}/#{prefix_name}" + Client.request(config, :delete, path, area: :prefix) + end + end + + @doc """ + Deletes a prefix mapping, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Prefix.delete!(config, "ex") + %{"api:status" => "api:success"} + + """ + @spec delete!(Config.t(), String.t(), [prefix_opt()]) :: map() + def delete!(config, prefix_name, opts \\ []) do + case delete(config, prefix_name, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @doc """ + Gets all prefix mappings for the database. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@base" => "terminusdb:///data/", "@schema" => "terminusdb:///schema#"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, prefixes} = TerminusDB.Prefix.all(config) + iex> prefixes["@schema"] + "terminusdb:///schema#" + + """ + @spec all(Config.t(), [prefix_opt()]) :: {:ok, map()} | {:error, Error.t()} + def all(config, opts \\ []) do + with {:ok, path} <- prefixes_path(config, opts) do + Client.request(config, :get, path, area: :prefix) + end + end + + @doc """ + Gets all prefix mappings, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@base" => "terminusdb:///data/", "@schema" => "terminusdb:///schema#"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Prefix.all!(config)["@schema"] + "terminusdb:///schema#" + + """ + @spec all!(Config.t(), [prefix_opt()]) :: map() + def all!(config, opts \\ []) do + case all(config, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end +end diff --git a/lib/terminus_db/remote.ex b/lib/terminus_db/remote.ex new file mode 100644 index 0000000..9a310fc --- /dev/null +++ b/lib/terminus_db/remote.ex @@ -0,0 +1,286 @@ +defmodule TerminusDB.Remote do + @moduledoc """ + Remote collaboration API for TerminusDB. + + Wraps the `/api/clone`, `/api/fetch`, `/api/push`, and `/api/pull` endpoints + for working with remote TerminusDB repositories. + + All functions require a `TerminusDB.Config` scoped to a database (except + `clone/3` which creates a new database). + + ## Quick start + + config = + TerminusDB.Config.new(endpoint: "http://localhost:6363") + + # Clone a remote database + {:ok, _} = TerminusDB.Remote.clone(config, "https://data.terminusdb.org/public/star-wars", "star-wars", + label: "Star Wars", comment: "Star Wars dataset") + + # Push current branch to remote + {:ok, _} = TerminusDB.Remote.push(config, "origin", "main", + author: "admin", message: "push changes") + + """ + + alias TerminusDB.{Client, Config, Error} + + @type remote_opt :: + {:organization, String.t()} + | {:repo, String.t()} + | {:label, String.t()} + | {:comment, String.t()} + | {:author, String.t()} + | {:message, String.t()} + | {:remote_branch, String.t()} + | {:push_prefixes, boolean()} + + @doc """ + Clones a remote repository into a new local database. + + ## Options + + - `:label` — database label (defaults to `newid`). + - `:comment` — database comment (defaults to `""`). + - `:organization` — target organization (defaults to `config.organization`). + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) + iex> {:ok, resp} = TerminusDB.Remote.clone(config, "https://data.terminusdb.org/public/star-wars", "star-wars", label: "Star Wars") + iex> resp["api:status"] + "api:success" + + """ + @spec clone(Config.t(), String.t(), String.t(), [remote_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def clone(config, remote_url, newid, opts \\ []) do + org = opts[:organization] || config.organization + path = "clone/#{org}/#{newid}" + + body = %{ + "remote_url" => remote_url, + "label" => opts[:label] || newid, + "comment" => opts[:comment] || "" + } + + Client.request(config, :post, path, json: body, area: :remote) + end + + @doc """ + Clones a remote repository, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) + iex> TerminusDB.Remote.clone!(config, "https://data.terminusdb.org/public/star-wars", "star-wars") + %{"api:status" => "api:success"} + + """ + @spec clone!(Config.t(), String.t(), String.t(), [remote_opt()]) :: map() + def clone!(config, remote_url, newid, opts \\ []) do + case clone(config, remote_url, newid, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @doc """ + Fetches branches from a remote repository. + + ## Options + + - `:organization` — overrides `config.organization`. + - `:repo` — overrides `config.repo`. + - `:remote_id` — remote repository ID (default `"origin"`). + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success", "api:head_has_changed" => true})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Remote.fetch(config) + iex> resp["api:head_has_changed"] + true + + """ + @spec fetch(Config.t(), [remote_opt()]) :: {:ok, map()} | {:error, Error.t()} + def fetch(config, opts \\ []) do + org = opts[:organization] || config.organization + db = config.database || raise Error, reason: :http, message: "no database scoped in config" + repo = opts[:repo] || config.repo + branch = config.branch + remote_id = opts[:remote_id] || "origin" + + path = "fetch/#{org}/#{db}/#{repo}/branch/#{branch}/#{remote_id}/_commits" + Client.request(config, :post, path, area: :remote) + end + + @doc """ + Fetches from remote, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success", "api:head_has_changed" => false})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Remote.fetch!(config) + %{"api:head_has_changed" => false} + + """ + @spec fetch!(Config.t(), [remote_opt()]) :: map() + def fetch!(config, opts \\ []) do + case fetch(config, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @doc """ + Pushes the current branch to a remote repository. + + ## Options + + - `:remote` — remote name (default `"origin"`). + - `:remote_branch` — remote branch name (default `config.branch`). + - `:author` — commit author. + - `:message` — commit message. + - `:organization` — overrides `config.organization`. + - `:repo` — overrides `config.repo`. + - `:push_prefixes` — also push prefixes (default `false`). + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success", "api:repo_head_updated" => true})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Remote.push(config, "origin", "main", author: "admin", message: "push") + iex> resp["api:repo_head_updated"] + true + + """ + @spec push(Config.t(), String.t(), String.t(), [remote_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def push(config, remote, remote_branch, opts \\ []) do + org = opts[:organization] || config.organization + db = config.database || raise Error, reason: :http, message: "no database scoped in config" + repo = opts[:repo] || config.repo + branch = config.branch + + path = "push/#{org}/#{db}/#{repo}/branch/#{branch}" + + body = %{ + "remote" => remote, + "remote_branch" => remote_branch + } + + body = + body + |> maybe_put_string("author", opts[:author]) + |> maybe_put_string("message", opts[:message]) + + params = + if opts[:push_prefixes] do + [push_prefixes: true] + else + [] + end + + Client.request(config, :post, path, json: body, params: params, area: :remote) + end + + @doc """ + Pushes to remote, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success", "api:repo_head_updated" => true})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Remote.push!(config, "origin", "main") + %{"api:repo_head_updated" => true} + + """ + @spec push!(Config.t(), String.t(), String.t(), [remote_opt()]) :: map() + def push!(config, remote, remote_branch, opts \\ []) do + case push(config, remote, remote_branch, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @doc """ + Pulls updates from a remote repository into the current branch. + + ## Options + + Same as `push/4`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Remote.pull(config, "origin", "main", author: "admin", message: "pull") + iex> resp["api:status"] + "api:success" + + """ + @spec pull(Config.t(), String.t(), String.t(), [remote_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def pull(config, remote, remote_branch, opts \\ []) do + org = opts[:organization] || config.organization + db = config.database || raise Error, reason: :http, message: "no database scoped in config" + repo = opts[:repo] || config.repo + branch = config.branch + + path = "pull/#{org}/#{db}/#{repo}/branch/#{branch}" + + body = %{ + "remote" => remote, + "remote_branch" => remote_branch + } + + body = + body + |> maybe_put_string("author", opts[:author]) + |> maybe_put_string("message", opts[:message]) + + Client.request(config, :post, path, json: body, area: :remote) + end + + @doc """ + Pulls from remote, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Remote.pull!(config, "origin", "main") + %{"api:status" => "api:success"} + + """ + @spec pull!(Config.t(), String.t(), String.t(), [remote_opt()]) :: map() + def pull!(config, remote, remote_branch, opts \\ []) do + case pull(config, remote, remote_branch, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + defp maybe_put_string(map, _key, nil), do: map + defp maybe_put_string(map, key, value), do: Map.put(map, key, value) +end diff --git a/lib/terminus_db/telemetry.ex b/lib/terminus_db/telemetry.ex index 3a2d49e..90f1de7 100644 --- a/lib/terminus_db/telemetry.ex +++ b/lib/terminus_db/telemetry.ex @@ -8,7 +8,8 @@ defmodule TerminusDB.Telemetry do [:terminusdb, , :stop] where `` is one of `:database`, `:document`, `:query`, `:branch`, - `:merge`, `:diff`, or `:connection`. + `:merge`, `:diff`, `:commit`, `:woql`, `:graphql`, `:prefix`, `:triples`, + `:remote`, or `:connection`. ## Measurements @@ -47,6 +48,10 @@ defmodule TerminusDB.Telemetry do | :diff | :commit | :woql + | :graphql + | :prefix + | :triples + | :remote | :connection @doc """ diff --git a/lib/terminus_db/triples.ex b/lib/terminus_db/triples.ex new file mode 100644 index 0000000..4915b36 --- /dev/null +++ b/lib/terminus_db/triples.ex @@ -0,0 +1,200 @@ +defmodule TerminusDB.Triples do + @moduledoc """ + Triples (turtle) API for TerminusDB. + + Wraps the `/api/triples/{path}/{graph_type}` endpoints for reading and + writing graph contents as turtle-encoded triples. + + All functions require a `TerminusDB.Config` scoped to a database. + + ## Quick start + + config = + TerminusDB.Config.new(endpoint: "http://localhost:6363") + |> TerminusDB.Config.with_database("mydb") + + {:ok, turtle} = TerminusDB.Triples.get(config) + {:ok, _} = TerminusDB.Triples.update(config, turtle, author: "admin", message: "replace") + {:ok, _} = TerminusDB.Triples.insert(config, more_turtle, author: "admin", message: "add") + + """ + + alias TerminusDB.{Client, Config, Error} + alias TerminusDB.Client.Params + + @type triples_opt :: + {:graph_type, :instance | :schema} + | {:organization, String.t()} + | {:repo, String.t()} + | {:author, String.t()} + | {:message, String.t()} + + defp triples_path(config, opts) do + org = opts[:organization] || config.organization + db = config.database || raise Error, reason: :http, message: "no database scoped in config" + repo = opts[:repo] || config.repo + branch = config.branch + graph_type = opts[:graph_type] || :instance + "triples/#{org}/#{db}/#{repo}/branch/#{branch}/#{graph_type}" + end + + defp build_commit_info(opts) do + author = opts[:author] + message = opts[:message] + + if author || message do + %{"author" => author || "", "message" => message || ""} + end + end + + @doc """ + Retrieves the contents of the specified graph as turtle-encoded triples. + + ## Options + + - `:graph_type` — `:instance` (default) or `:schema`. + - `:organization` — overrides `config.organization`. + - `:repo` — overrides `config.repo`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: "@prefix : .")} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, turtle} = TerminusDB.Triples.get(config) + iex> turtle + "@prefix : ." + + """ + @spec get(Config.t(), [triples_opt()]) :: {:ok, String.t()} | {:error, Error.t()} + def get(config, opts \\ []) do + path = triples_path(config, opts) + Client.request(config, :get, path, area: :triples) + end + + @doc """ + Retrieves triples, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: " .")} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Triples.get!(config) + " ." + + """ + @spec get!(Config.t(), [triples_opt()]) :: String.t() + def get!(config, opts \\ []) do + case get(config, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @doc """ + Replaces the entire graph with the given turtle triples. + + ## Options + + - `:author` — commit author. + - `:message` — commit message. + - `:graph_type` — `:instance` (default) or `:schema`. + - `:organization` — overrides `config.organization`. + - `:repo` — overrides `config.repo`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Triples.update(config, "@prefix : .", author: "admin", message: "replace") + iex> resp["api:status"] + "api:success" + + """ + @spec update(Config.t(), String.t(), [triples_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def update(config, content, opts \\ []) do + path = triples_path(config, opts) + + body = + Params.maybe_put(%{"turtle" => content}, "commit_info", build_commit_info(opts)) + + Client.request(config, :post, path, json: body, area: :triples) + end + + @doc """ + Replaces graph with turtle triples, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Triples.update!(config, "@prefix : .") + %{"api:status" => "api:success"} + + """ + @spec update!(Config.t(), String.t(), [triples_opt()]) :: map() + def update!(config, content, opts \\ []) do + case update(config, content, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end + + @doc """ + Inserts turtle triples into the graph (additive). + + ## Options + + Same as `update/3`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> {:ok, resp} = TerminusDB.Triples.insert(config, ":foo :bar :baz .", author: "admin", message: "add") + iex> resp["api:status"] + "api:success" + + """ + @spec insert(Config.t(), String.t(), [triples_opt()]) :: + {:ok, map()} | {:error, Error.t()} + def insert(config, content, opts \\ []) do + path = triples_path(config, opts) + + body = + Params.maybe_put(%{"turtle" => content}, "commit_info", build_commit_info(opts)) + + Client.request(config, :put, path, json: body, area: :triples) + end + + @doc """ + Inserts turtle triples, or raises. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> TerminusDB.Triples.insert!(config, ":foo :bar :baz .") + %{"api:status" => "api:success"} + + """ + @spec insert!(Config.t(), String.t(), [triples_opt()]) :: map() + def insert!(config, content, opts \\ []) do + case insert(config, content, opts) do + {:ok, body} -> body + {:error, error} -> raise error + end + end +end diff --git a/lib/terminus_db/woql.ex b/lib/terminus_db/woql.ex index 2f3c8c0..bb4d353 100644 --- a/lib/terminus_db/woql.ex +++ b/lib/terminus_db/woql.ex @@ -3,12 +3,12 @@ defmodule TerminusDB.WOQL do A functional builder DSL for WOQL (Web Object Query Language). WOQL is TerminusDB's Datalog-based query language. This module provides a - comprehensive set of ~70 composable functions that build a `TerminusDB.WOQL.Query` + comprehensive set of ~100 composable functions that build a `TerminusDB.WOQL.Query` struct, which can be serialized to the JSON-LD wire format via `to_jsonld/1` and executed via `TerminusDB.WOQL.execute/3`. - This is WOQL DSL v0.2 (ADR-0008) — covering the core and important-advanced - vocabulary (Tier 1+2) matching the Python/JS clients. + This is WOQL DSL v0.2 (ADR-0008) extended in v0.3.2 with temporal/Allen, + CSV/IO, range queries, and an RDF list library. ## Design @@ -165,6 +165,54 @@ defmodule TerminusDB.WOQL do | `size/2` | `Size` | | `triple_count/2` | `TripleCount` | + ### Range queries + + | Function | WOQL JSON-LD type | + | --- | --- | + | `triple_slice/5`, `quad_slice/6` | `TripleSlice` | + | `triple_slice_rev/5`, `quad_slice_rev/6` | `TripleSliceRev` | + | `triple_next/4`, `quad_next/5` | `TripleNext` | + | `triple_previous/4`, `quad_previous/5` | `TriplePrevious` | + + ### Temporal / Allen interval algebra + + | Function | WOQL JSON-LD type | + | --- | --- | + | `interval/3` | `Interval` | + | `interval_start_duration/3` | `IntervalStartDuration` | + | `interval_duration_end/3` | `IntervalDurationEnd` | + | `interval_relation/5` | `IntervalRelation` | + | `interval_relation_typed/3` | `IntervalRelationTyped` | + | `date_duration/3` | `DateDuration` | + | `day_after/2` | `DayAfter` | + | `day_before/2` | `DayBefore` | + | `weekday/2` | `Weekday` | + | `weekday_sunday_start/2` | `WeekdaySundayStart` | + | `iso_week/3` | `IsoWeek` | + | `month_start_date/2` | `MonthStartDate` | + | `month_end_date/2` | `MonthEndDate` | + | `month_start_dates/3` | `MonthStartDates` | + | `month_end_dates/3` | `MonthEndDates` | + | `in_range/3` | `InRange` | + | `sequence/5` | `Sequence` | + | `range_min/2` | `RangeMin` | + | `range_max/2` | `RangeMax` | + + ### CSV / IO + + | Function | WOQL JSON-LD type | + | --- | --- | + | `get/2` | `Get` | + | `put/3` | `Put` | + | `woql_as/1` | `Column`/`Indicator` (helper) | + | `file/2` | `QueryResource` | + | `remote/2` | `QueryResource` | + | `post/2` | `QueryResource` | + + ### RDF list library + + See `TerminusDB.WOQL.RDFList` for 17 RDF list manipulation functions. + ### Literal / value helpers | Function | Description | @@ -207,7 +255,7 @@ defmodule TerminusDB.WOQL do @type woql_var :: String.t() @type woql_node :: String.t() | woql_var() - @type value :: String.t() | woql_var() | number() | boolean() | map() + @type value :: String.t() | woql_var() | number() | boolean() | map() | [value()] # -------------------------------------------------------------------------- # Query builders @@ -1702,6 +1750,567 @@ defmodule TerminusDB.WOQL do %__MODULE__{op: :triple_count, args: [graph, count_var]} end + # -------------------------------------------------------------------------- + # Range queries + # -------------------------------------------------------------------------- + + @doc """ + Builds a `TripleSlice` — triple pattern with half-open value range + [low, high) on the object. + + ## Examples + + iex> q = TerminusDB.WOQL.triple_slice("v:S", "v:P", "v:O", 10, 100) + iex> q.op + :triple_slice + + """ + @spec triple_slice(woql_node(), woql_node(), value(), value(), value()) :: t() + def triple_slice(subject, predicate, object, low, high) do + %__MODULE__{op: :triple_slice, args: [subject, predicate, object, low, high]} + end + + @doc """ + Builds a `TripleSlice` with an explicit graph selector. + + ## Examples + + iex> q = TerminusDB.WOQL.quad_slice("v:S", "v:P", "v:O", 10, 100, "instance") + iex> q.op + :triple_slice + + """ + @spec quad_slice(woql_node(), woql_node(), value(), value(), value(), String.t()) :: t() + def quad_slice(subject, predicate, object, low, high, graph) do + %__MODULE__{op: :triple_slice, args: [subject, predicate, object, low, high, graph]} + end + + @doc """ + Builds a `TripleSliceRev` — same as `triple_slice/5` but iterates in + descending order (high to low). + + ## Examples + + iex> q = TerminusDB.WOQL.triple_slice_rev("v:S", "v:P", "v:O", 10, 100) + iex> q.op + :triple_slice_rev + + """ + @spec triple_slice_rev(woql_node(), woql_node(), value(), value(), value()) :: t() + def triple_slice_rev(subject, predicate, object, low, high) do + %__MODULE__{op: :triple_slice_rev, args: [subject, predicate, object, low, high]} + end + + @doc """ + Builds a `TripleSliceRev` with an explicit graph selector. + + ## Examples + + iex> q = TerminusDB.WOQL.quad_slice_rev("v:S", "v:P", "v:O", 10, 100, "instance") + iex> q.op + :triple_slice_rev + + """ + @spec quad_slice_rev(woql_node(), woql_node(), value(), value(), value(), String.t()) :: t() + def quad_slice_rev(subject, predicate, object, low, high, graph) do + %__MODULE__{op: :triple_slice_rev, args: [subject, predicate, object, low, high, graph]} + end + + @doc """ + Builds a `TripleNext` — finds the next object value after a reference. + When object is bound and next is free, finds the smallest next > object. + + ## Examples + + iex> q = TerminusDB.WOQL.triple_next("v:S", "v:P", "v:O", "v:Next") + iex> q.op + :triple_next + + """ + @spec triple_next(woql_node(), woql_node(), value(), value()) :: t() + def triple_next(subject, predicate, object, next_val) do + %__MODULE__{op: :triple_next, args: [subject, predicate, object, next_val]} + end + + @doc """ + Builds a `TripleNext` with an explicit graph selector. + + ## Examples + + iex> q = TerminusDB.WOQL.quad_next("v:S", "v:P", "v:O", "v:Next", "instance") + iex> q.op + :triple_next + + """ + @spec quad_next(woql_node(), woql_node(), value(), value(), String.t()) :: t() + def quad_next(subject, predicate, object, next_val, graph) do + %__MODULE__{op: :triple_next, args: [subject, predicate, object, next_val, graph]} + end + + @doc """ + Builds a `TriplePrevious` — finds the previous object value before a + reference. When object is bound and previous is free, finds the largest + previous < object. + + ## Examples + + iex> q = TerminusDB.WOQL.triple_previous("v:S", "v:P", "v:O", "v:Prev") + iex> q.op + :triple_previous + + """ + @spec triple_previous(woql_node(), woql_node(), value(), value()) :: t() + def triple_previous(subject, predicate, object, prev_val) do + %__MODULE__{op: :triple_previous, args: [subject, predicate, object, prev_val]} + end + + @doc """ + Builds a `TriplePrevious` with an explicit graph selector. + + ## Examples + + iex> q = TerminusDB.WOQL.quad_previous("v:S", "v:P", "v:O", "v:Prev", "instance") + iex> q.op + :triple_previous + + """ + @spec quad_previous(woql_node(), woql_node(), value(), value(), String.t()) :: t() + def quad_previous(subject, predicate, object, prev_val, graph) do + %__MODULE__{op: :triple_previous, args: [subject, predicate, object, prev_val, graph]} + end + + # -------------------------------------------------------------------------- + # Temporal / Allen interval algebra + # -------------------------------------------------------------------------- + + @doc """ + Builds an `Interval` — constructs/deconstructs a half-open + `xdd:dateTimeInterval` [start, end). + + ## Examples + + iex> q = TerminusDB.WOQL.interval("v:Start", "v:End", "v:I") + iex> q.op + :interval + + """ + @spec interval(value(), value(), value()) :: t() + def interval(start_val, end_val, interval_val) do + %__MODULE__{op: :interval, args: [start_val, end_val, interval_val]} + end + + @doc """ + Builds an `IntervalStartDuration` — relates interval to start endpoint + and precise `xsd:duration`. + + ## Examples + + iex> q = TerminusDB.WOQL.interval_start_duration("v:Start", "v:Dur", "v:I") + iex> q.op + :interval_start_duration + + """ + @spec interval_start_duration(value(), value(), value()) :: t() + def interval_start_duration(start_val, duration, interval_val) do + %__MODULE__{op: :interval_start_duration, args: [start_val, duration, interval_val]} + end + + @doc """ + Builds an `IntervalDurationEnd` — relates interval to end endpoint + and precise `xsd:duration`. + + ## Examples + + iex> q = TerminusDB.WOQL.interval_duration_end("v:Dur", "v:End", "v:I") + iex> q.op + :interval_duration_end + + """ + @spec interval_duration_end(value(), value(), value()) :: t() + def interval_duration_end(duration, end_val, interval_val) do + %__MODULE__{op: :interval_duration_end, args: [duration, end_val, interval_val]} + end + + @doc """ + Builds an `IntervalRelation` — Allen's Interval Algebra: classifies + the relationship between two half-open intervals. + + ## Examples + + iex> q = TerminusDB.WOQL.interval_relation("v:Rel", "v:XS", "v:XE", "v:YS", "v:YE") + iex> q.op + :interval_relation + + """ + @spec interval_relation(value(), value(), value(), value(), value()) :: t() + def interval_relation(relation, x_start, x_end, y_start, y_end) do + %__MODULE__{op: :interval_relation, args: [relation, x_start, x_end, y_start, y_end]} + end + + @doc """ + Builds an `IntervalRelationTyped` — Allen's Interval Algebra on + `xdd:dateTimeInterval` values. + + ## Examples + + iex> q = TerminusDB.WOQL.interval_relation_typed("v:Rel", "v:X", "v:Y") + iex> q.op + :interval_relation_typed + + """ + @spec interval_relation_typed(value(), value(), value()) :: t() + def interval_relation_typed(relation, x, y) do + %__MODULE__{op: :interval_relation_typed, args: [relation, x, y]} + end + + @doc """ + Builds a `DateDuration` — tri-directional duration arithmetic for + dates/dateTimes (end-of-month preserving). + + ## Examples + + iex> q = TerminusDB.WOQL.date_duration("v:Start", "v:End", "v:Dur") + iex> q.op + :date_duration + + """ + @spec date_duration(value(), value(), value()) :: t() + def date_duration(start_val, end_val, duration) do + %__MODULE__{op: :date_duration, args: [start_val, end_val, duration]} + end + + @doc """ + Builds a `DayAfter` — computes the calendar day after the given date + (bidirectional). + + ## Examples + + iex> q = TerminusDB.WOQL.day_after("v:Date", "v:Next") + iex> q.op + :day_after + + """ + @spec day_after(value(), value()) :: t() + def day_after(date, next_date) do + %__MODULE__{op: :day_after, args: [date, next_date]} + end + + @doc """ + Builds a `DayBefore` — computes the calendar day before the given date + (bidirectional). + + ## Examples + + iex> q = TerminusDB.WOQL.day_before("v:Date", "v:Prev") + iex> q.op + :day_before + + """ + @spec day_before(value(), value()) :: t() + def day_before(date, previous) do + %__MODULE__{op: :day_before, args: [date, previous]} + end + + @doc """ + Builds a `Weekday` — computes ISO 8601 weekday number (Monday=1, + Sunday=7). + + ## Examples + + iex> q = TerminusDB.WOQL.weekday("v:Date", "v:Day") + iex> q.op + :weekday + + """ + @spec weekday(value(), value()) :: t() + def weekday(date, weekday_val) do + %__MODULE__{op: :weekday, args: [date, weekday_val]} + end + + @doc """ + Builds a `WeekdaySundayStart` — computes US-convention weekday + (Sunday=1, Saturday=7). + + ## Examples + + iex> q = TerminusDB.WOQL.weekday_sunday_start("v:Date", "v:Day") + iex> q.op + :weekday_sunday_start + + """ + @spec weekday_sunday_start(value(), value()) :: t() + def weekday_sunday_start(date, weekday_val) do + %__MODULE__{op: :weekday_sunday_start, args: [date, weekday_val]} + end + + @doc """ + Builds an `IsoWeek` — computes ISO 8601 week-numbering year and + week number. + + ## Examples + + iex> q = TerminusDB.WOQL.iso_week("v:Date", "v:Year", "v:Week") + iex> q.op + :iso_week + + """ + @spec iso_week(value(), value(), value()) :: t() + def iso_week(date, year, week) do + %__MODULE__{op: :iso_week, args: [date, year, week]} + end + + @doc """ + Builds a `MonthStartDate` — first day of the month for a given + `xsd:gYearMonth`. + + ## Examples + + iex> q = TerminusDB.WOQL.month_start_date("v:YM", "v:Date") + iex> q.op + :month_start_date + + """ + @spec month_start_date(value(), value()) :: t() + def month_start_date(year_month, date) do + %__MODULE__{op: :month_start_date, args: [year_month, date]} + end + + @doc """ + Builds a `MonthEndDate` — last day of the month for a given + `xsd:gYearMonth` (handles leap years). + + ## Examples + + iex> q = TerminusDB.WOQL.month_end_date("v:YM", "v:Date") + iex> q.op + :month_end_date + + """ + @spec month_end_date(value(), value()) :: t() + def month_end_date(year_month, date) do + %__MODULE__{op: :month_end_date, args: [year_month, date]} + end + + @doc """ + Builds a `MonthStartDates` — generator: every first-of-month date + in [start, end). + + ## Examples + + iex> q = TerminusDB.WOQL.month_start_dates("v:Date", "v:Start", "v:End") + iex> q.op + :month_start_dates + + """ + @spec month_start_dates(value(), value(), value()) :: t() + def month_start_dates(date, start_val, end_val) do + %__MODULE__{op: :month_start_dates, args: [date, start_val, end_val]} + end + + @doc """ + Builds a `MonthEndDates` — generator: every last-of-month date + in [start, end). + + ## Examples + + iex> q = TerminusDB.WOQL.month_end_dates("v:Date", "v:Start", "v:End") + iex> q.op + :month_end_dates + + """ + @spec month_end_dates(value(), value(), value()) :: t() + def month_end_dates(date, start_val, end_val) do + %__MODULE__{op: :month_end_dates, args: [date, start_val, end_val]} + end + + @doc """ + Builds an `InRange` — tests whether value falls within half-open + range [start, end). + + ## Examples + + iex> q = TerminusDB.WOQL.in_range("v:Val", 10, 100) + iex> q.op + :in_range + + """ + @spec in_range(value(), value(), value()) :: t() + def in_range(value, start_val, end_val) do + %__MODULE__{op: :in_range, args: [value, start_val, end_val]} + end + + @doc """ + Builds a `Sequence` — generates a sequence of values in half-open + [start, end) via backtracking. `step` and `count` are optional + (default `nil`). + + ## Examples + + iex> q = TerminusDB.WOQL.sequence("v:V", 1, 10) + iex> q.op + :sequence + + """ + @spec sequence(value(), value(), value(), value() | nil, value() | nil) :: t() + def sequence(value, start_val, end_val, step \\ nil, count \\ nil) do + %__MODULE__{op: :sequence, args: [value, start_val, end_val, step, count]} + end + + @doc """ + Builds a `RangeMin` — find minimum value in a list (any comparable + types). + + ## Examples + + iex> q = TerminusDB.WOQL.range_min("v:List", "v:Min") + iex> q.op + :range_min + + """ + @spec range_min(value(), value()) :: t() + def range_min(input_list, result) do + %__MODULE__{op: :range_min, args: [input_list, result]} + end + + @doc """ + Builds a `RangeMax` — find maximum value in a list (any comparable + types). + + ## Examples + + iex> q = TerminusDB.WOQL.range_max("v:List", "v:Max") + iex> q.op + :range_max + + """ + @spec range_max(value(), value()) :: t() + def range_max(input_list, result) do + %__MODULE__{op: :range_max, args: [input_list, result]} + end + + # -------------------------------------------------------------------------- + # CSV / IO + # -------------------------------------------------------------------------- + + @doc """ + Builds a `Get` — reads a CSV/columns resource. + + `as_vars` is a list of `Column` objects built by `woql_as/1`. + `query_resource` is built by `file/2`, `remote/2`, or `post/2`. + + ## Examples + + iex> q = TerminusDB.WOQL.get(TerminusDB.WOQL.woql_as([{"name", "v:Name"}]), TerminusDB.WOQL.file("data.csv")) + iex> q.op + :get + + """ + @spec get([map()], t()) :: t() + def get(as_vars, query_resource) do + %__MODULE__{op: :get, args: [as_vars, query_resource]} + end + + @doc """ + Builds a `Put` — writes an array of variables + optional column + names to a resource. + + ## Examples + + iex> q = TerminusDB.WOQL.put(TerminusDB.WOQL.woql_as([{"name", "v:Name"}]), TerminusDB.WOQL.triple("v:S", "p", "v:O"), TerminusDB.WOQL.file("out.csv")) + iex> q.op + :put + + """ + @spec put([map()], t(), t()) :: t() + def put(as_vars, query, query_resource) do + %__MODULE__{op: :put, args: [as_vars, query, query_resource]} + end + + @doc """ + Builds a list of `Column`/`Indicator` JSON-LD objects for use with + `get/2` and `put/3`. + + Accepts a list of `{name_or_index, variable}` tuples. + + ## Examples + + iex> cols = TerminusDB.WOQL.woql_as([{"name", "v:Name"}, {0, "v:Idx"}]) + iex> length(cols) + 2 + iex> hd(cols)["@type"] + "Column" + + """ + @spec woql_as([{String.t() | non_neg_integer(), woql_var()}]) :: [map()] + def woql_as(specs) when is_list(specs) do + Enum.map(specs, &build_as_column/1) + end + + defp build_as_column({name, var}) when is_binary(name) do + var_name = if String.starts_with?(var, "v:"), do: String.slice(var, 2..-1//1), else: var + + %{ + "@type" => "Column", + "indicator" => %{"@type" => "Indicator", "name" => name}, + "variable" => var_name + } + end + + defp build_as_column({index, var}) when is_integer(index) do + var_name = if String.starts_with?(var, "v:"), do: String.slice(var, 2..-1//1), else: var + + %{ + "@type" => "Column", + "indicator" => %{"@type" => "Indicator", "index" => index}, + "variable" => var_name + } + end + + @doc """ + Builds a `QueryResource` for a file source (CSV format by default). + + ## Examples + + iex> q = TerminusDB.WOQL.file("data.csv") + iex> q.op + :file + + """ + @spec file(String.t(), keyword()) :: t() + def file(fpath, opts \\ []) do + %__MODULE__{op: :file, args: [fpath, opts[:format] || "csv"]} + end + + @doc """ + Builds a `QueryResource` for a remote URL data source. + + ## Examples + + iex> q = TerminusDB.WOQL.remote("https://example.com/data.csv") + iex> q.op + :remote + + """ + @spec remote(String.t(), keyword()) :: t() + def remote(uri, opts \\ []) do + %__MODULE__{op: :remote, args: [uri, opts[:format] || "csv"]} + end + + @doc """ + Builds a `QueryResource` for a file posted as part of the request. + + ## Examples + + iex> q = TerminusDB.WOQL.post("upload.csv") + iex> q.op + :post + + """ + @spec post(String.t(), keyword()) :: t() + def post(fpath, opts \\ []) do + %__MODULE__{op: :post, args: [fpath, opts[:format] || "csv"]} + end + # -------------------------------------------------------------------------- # Serialization # -------------------------------------------------------------------------- @@ -1818,6 +2427,83 @@ defmodule TerminusDB.WOQL do end end + @doc """ + Executes a WOQL query and returns a lazy `Stream` of binding maps. + + The stream uses the PrefaceRecord/Binding/PostscriptRecord protocol for + incremental delivery. Each element is a `%{"@type" => "Binding", ...}` map. + + ## Options + + Same as `execute/3`. + + ## Examples + + iex> config = TerminusDB.Config.new( + ...> endpoint: "http://localhost:6363", + ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"@type" => "PrefaceRecord", "names" => ["Name"]}, %{"@type" => "Binding", "Name" => "Alice"}, %{"@type" => "PostscriptRecord"}])} end + ...> ) |> TerminusDB.Config.with_database("mydb") + iex> q = TerminusDB.WOQL.select(["v:Name"], TerminusDB.WOQL.triple("v:P", "name", "v:Name")) + iex> {:ok, stream} = TerminusDB.WOQL.execute_stream(config, q) + iex> Enum.to_list(stream) + [%{"@type" => "Binding", "Name" => "Alice"}] + + """ + @spec execute_stream(Config.t(), t(), keyword()) :: + {:ok, Enumerable.t()} | {:error, Error.t()} + def execute_stream(config, %__MODULE__{} = query, opts \\ []) do + org = opts[:organization] || config.organization + + case config.database do + nil -> + {:error, %Error{reason: :config, message: "no database scoped in config"}} + + db -> + repo = opts[:repo] || config.repo + branch = opts[:branch] || config.branch + path = "woql/#{org}/#{db}/#{repo}/branch/#{branch}" + + body = + %{"query" => to_jsonld(query), "streaming" => true} + |> Params.maybe_put("commit_info", build_commit_info(opts)) + |> Params.maybe_put("all_witnesses", opts[:all_witnesses]) + + case Client.request_response(config, :post, path, + json: body, + decode_body: false, + area: :woql + ) do + {:ok, resp} -> + {:ok, woql_stream(resp)} + + {:error, _} = error -> + error + end + end + end + + defp woql_stream(resp) do + body = resp.body + + if is_binary(body) do + body + |> String.split("\n", trim: true) + |> Stream.map(fn line -> + case Jason.decode(line) do + {:ok, decoded} -> decoded + {:error, reason} -> {:stream_decode_error, line, reason} + end + end) + |> Stream.reject(fn + {:stream_decode_error, _, _} -> false + %{"@type" => type} when type in ["PrefaceRecord", "PostscriptRecord"] -> true + _ -> false + end) + else + Stream.reject(body, &(&1["@type"] == "PrefaceRecord" or &1["@type"] == "PostscriptRecord")) + end + end + defp build_commit_info(opts) do author = opts[:author] message = opts[:message] diff --git a/lib/terminus_db/woql/decoder.ex b/lib/terminus_db/woql/decoder.ex index de7d458..6a60a89 100644 --- a/lib/terminus_db/woql/decoder.ex +++ b/lib/terminus_db/woql/decoder.ex @@ -411,6 +411,277 @@ defmodule TerminusDB.WOQL.Decoder do TerminusDB.WOQL.triple_count(graph, decode_value(count_var)) end + def decode(%{"@type" => "TripleSlice", "graph" => graph} = m) do + TerminusDB.WOQL.quad_slice( + decode_node(m["subject"]), + decode_node(m["predicate"]), + decode_value(m["object"]), + decode_value(m["low"]), + decode_value(m["high"]), + graph + ) + end + + def decode(%{"@type" => "TripleSlice"} = m) do + TerminusDB.WOQL.triple_slice( + decode_node(m["subject"]), + decode_node(m["predicate"]), + decode_value(m["object"]), + decode_value(m["low"]), + decode_value(m["high"]) + ) + end + + def decode(%{"@type" => "TripleSliceRev", "graph" => graph} = m) do + TerminusDB.WOQL.quad_slice_rev( + decode_node(m["subject"]), + decode_node(m["predicate"]), + decode_value(m["object"]), + decode_value(m["low"]), + decode_value(m["high"]), + graph + ) + end + + def decode(%{"@type" => "TripleSliceRev"} = m) do + TerminusDB.WOQL.triple_slice_rev( + decode_node(m["subject"]), + decode_node(m["predicate"]), + decode_value(m["object"]), + decode_value(m["low"]), + decode_value(m["high"]) + ) + end + + def decode(%{"@type" => "TripleNext", "graph" => graph} = m) do + TerminusDB.WOQL.quad_next( + decode_node(m["subject"]), + decode_node(m["predicate"]), + decode_value(m["object"]), + decode_value(m["next"]), + graph + ) + end + + def decode(%{"@type" => "TripleNext"} = m) do + TerminusDB.WOQL.triple_next( + decode_node(m["subject"]), + decode_node(m["predicate"]), + decode_value(m["object"]), + decode_value(m["next"]) + ) + end + + def decode(%{"@type" => "TriplePrevious", "graph" => graph} = m) do + TerminusDB.WOQL.quad_previous( + decode_node(m["subject"]), + decode_node(m["predicate"]), + decode_value(m["object"]), + decode_value(m["previous"]), + graph + ) + end + + def decode(%{"@type" => "TriplePrevious"} = m) do + TerminusDB.WOQL.triple_previous( + decode_node(m["subject"]), + decode_node(m["predicate"]), + decode_value(m["object"]), + decode_value(m["previous"]) + ) + end + + # -------------------------------------------------------------------------- + # Temporal / Allen interval algebra + # -------------------------------------------------------------------------- + + def decode(%{"@type" => "Interval"} = m) do + TerminusDB.WOQL.interval( + decode_value(m["start"]), + decode_value(m["end"]), + decode_value(m["interval"]) + ) + end + + def decode(%{"@type" => "IntervalStartDuration"} = m) do + TerminusDB.WOQL.interval_start_duration( + decode_value(m["start"]), + decode_value(m["duration"]), + decode_value(m["interval"]) + ) + end + + def decode(%{"@type" => "IntervalDurationEnd"} = m) do + TerminusDB.WOQL.interval_duration_end( + decode_value(m["duration"]), + decode_value(m["end"]), + decode_value(m["interval"]) + ) + end + + def decode(%{"@type" => "IntervalRelation"} = m) do + TerminusDB.WOQL.interval_relation( + decode_value(m["relation"]), + decode_value(m["start"]), + decode_value(m["end"]), + decode_value(m["start2"]), + decode_value(m["end2"]) + ) + end + + def decode(%{"@type" => "IntervalRelationTyped"} = m) do + TerminusDB.WOQL.interval_relation_typed( + decode_value(m["relation"]), + decode_value(m["left"]), + decode_value(m["right"]) + ) + end + + def decode(%{"@type" => "DateDuration"} = m) do + TerminusDB.WOQL.date_duration( + decode_value(m["start"]), + decode_value(m["end"]), + decode_value(m["duration"]) + ) + end + + def decode(%{"@type" => "DayAfter"} = m) do + TerminusDB.WOQL.day_after( + decode_value(m["date"]), + decode_value(m["next"]) + ) + end + + def decode(%{"@type" => "DayBefore"} = m) do + TerminusDB.WOQL.day_before( + decode_value(m["date"]), + decode_value(m["previous"]) + ) + end + + def decode(%{"@type" => "Weekday"} = m) do + TerminusDB.WOQL.weekday( + decode_value(m["date"]), + decode_value(m["weekday"]) + ) + end + + def decode(%{"@type" => "WeekdaySundayStart"} = m) do + TerminusDB.WOQL.weekday_sunday_start( + decode_value(m["date"]), + decode_value(m["weekday"]) + ) + end + + def decode(%{"@type" => "IsoWeek"} = m) do + TerminusDB.WOQL.iso_week( + decode_value(m["date"]), + decode_value(m["year"]), + decode_value(m["week"]) + ) + end + + def decode(%{"@type" => "MonthStartDate"} = m) do + TerminusDB.WOQL.month_start_date( + decode_value(m["year_month"]), + decode_value(m["date"]) + ) + end + + def decode(%{"@type" => "MonthEndDate"} = m) do + TerminusDB.WOQL.month_end_date( + decode_value(m["year_month"]), + decode_value(m["date"]) + ) + end + + def decode(%{"@type" => "MonthStartDates"} = m) do + TerminusDB.WOQL.month_start_dates( + decode_value(m["date"]), + decode_value(m["start"]), + decode_value(m["end"]) + ) + end + + def decode(%{"@type" => "MonthEndDates"} = m) do + TerminusDB.WOQL.month_end_dates( + decode_value(m["date"]), + decode_value(m["start"]), + decode_value(m["end"]) + ) + end + + def decode(%{"@type" => "InRange"} = m) do + TerminusDB.WOQL.in_range( + decode_value(m["value"]), + decode_value(m["start"]), + decode_value(m["end"]) + ) + end + + def decode(%{"@type" => "Sequence"} = m) do + step = if m["step"], do: decode_value(m["step"]), else: nil + count = if m["count"], do: decode_value(m["count"]), else: nil + + TerminusDB.WOQL.sequence( + decode_value(m["value"]), + decode_value(m["start"]), + decode_value(m["end"]), + step, + count + ) + end + + def decode(%{"@type" => "RangeMin"} = m) do + TerminusDB.WOQL.range_min( + decode_value(m["list"]), + decode_value(m["min"]) + ) + end + + def decode(%{"@type" => "RangeMax"} = m) do + TerminusDB.WOQL.range_max( + decode_value(m["list"]), + decode_value(m["max"]) + ) + end + + # -------------------------------------------------------------------------- + # CSV / IO + # -------------------------------------------------------------------------- + + def decode(%{"@type" => "Get"} = m) do + TerminusDB.WOQL.get(m["columns"], decode(m["resource"])) + end + + def decode(%{"@type" => "Put"} = m) do + TerminusDB.WOQL.put(m["columns"], decode(m["query"]), decode(m["resource"])) + end + + def decode(%{"@type" => "QueryResource"} = m) do + source = m["source"] + + case source["@type"] do + "FileResource" -> + format = decode_format(m["format"]) + TerminusDB.WOQL.file(source["file_name"], format: format) + + "RemoteResource" -> + format = decode_format(m["format"]) + TerminusDB.WOQL.remote(source["url"], format: format) + + "PostResource" -> + format = decode_format(m["format"]) + TerminusDB.WOQL.post(source["file_name"], format: format) + + _ -> + TerminusDB.WOQL.file(source["file_name"] || "", format: decode_format(m["format"])) + end + end + + defp decode_format(%{"format_type" => %{"@value" => format}}), do: format + defp decode_format(_), do: "csv" + # -------------------------------------------------------------------------- # NodeValue decoder # -------------------------------------------------------------------------- diff --git a/lib/terminus_db/woql/encoder.ex b/lib/terminus_db/woql/encoder.ex index e3fd56e..c111169 100644 --- a/lib/terminus_db/woql/encoder.ex +++ b/lib/terminus_db/woql/encoder.ex @@ -537,6 +537,322 @@ defmodule TerminusDB.WOQL.Encoder do %{"@type" => "TripleCount", "graph" => graph, "triple_count" => encode_value(count_var)} end + def encode(%TerminusDB.WOQL{op: :triple_slice, args: [s, p, o, low, high]}) do + %{ + "@type" => "TripleSlice", + "subject" => encode_node(s), + "predicate" => encode_node(p), + "object" => encode_value(o), + "low" => encode_value(low), + "high" => encode_value(high) + } + end + + def encode(%TerminusDB.WOQL{op: :triple_slice, args: [s, p, o, low, high, graph]}) do + %{ + "@type" => "TripleSlice", + "subject" => encode_node(s), + "predicate" => encode_node(p), + "object" => encode_value(o), + "low" => encode_value(low), + "high" => encode_value(high), + "graph" => graph + } + end + + def encode(%TerminusDB.WOQL{op: :triple_slice_rev, args: [s, p, o, low, high]}) do + %{ + "@type" => "TripleSliceRev", + "subject" => encode_node(s), + "predicate" => encode_node(p), + "object" => encode_value(o), + "low" => encode_value(low), + "high" => encode_value(high) + } + end + + def encode(%TerminusDB.WOQL{op: :triple_slice_rev, args: [s, p, o, low, high, graph]}) do + %{ + "@type" => "TripleSliceRev", + "subject" => encode_node(s), + "predicate" => encode_node(p), + "object" => encode_value(o), + "low" => encode_value(low), + "high" => encode_value(high), + "graph" => graph + } + end + + def encode(%TerminusDB.WOQL{op: :triple_next, args: [s, p, o, next_val]}) do + %{ + "@type" => "TripleNext", + "subject" => encode_node(s), + "predicate" => encode_node(p), + "object" => encode_value(o), + "next" => encode_value(next_val) + } + end + + def encode(%TerminusDB.WOQL{op: :triple_next, args: [s, p, o, next_val, graph]}) do + %{ + "@type" => "TripleNext", + "subject" => encode_node(s), + "predicate" => encode_node(p), + "object" => encode_value(o), + "next" => encode_value(next_val), + "graph" => graph + } + end + + def encode(%TerminusDB.WOQL{op: :triple_previous, args: [s, p, o, prev_val]}) do + %{ + "@type" => "TriplePrevious", + "subject" => encode_node(s), + "predicate" => encode_node(p), + "object" => encode_value(o), + "previous" => encode_value(prev_val) + } + end + + def encode(%TerminusDB.WOQL{op: :triple_previous, args: [s, p, o, prev_val, graph]}) do + %{ + "@type" => "TriplePrevious", + "subject" => encode_node(s), + "predicate" => encode_node(p), + "object" => encode_value(o), + "previous" => encode_value(prev_val), + "graph" => graph + } + end + + # -------------------------------------------------------------------------- + # Temporal / Allen interval algebra + # -------------------------------------------------------------------------- + + def encode(%TerminusDB.WOQL{op: :interval, args: [s, e, i]}) do + %{ + "@type" => "Interval", + "start" => encode_value(s), + "end" => encode_value(e), + "interval" => encode_value(i) + } + end + + def encode(%TerminusDB.WOQL{op: :interval_start_duration, args: [s, d, i]}) do + %{ + "@type" => "IntervalStartDuration", + "start" => encode_value(s), + "duration" => encode_value(d), + "interval" => encode_value(i) + } + end + + def encode(%TerminusDB.WOQL{op: :interval_duration_end, args: [d, e, i]}) do + %{ + "@type" => "IntervalDurationEnd", + "duration" => encode_value(d), + "end" => encode_value(e), + "interval" => encode_value(i) + } + end + + def encode(%TerminusDB.WOQL{op: :interval_relation, args: [r, xs, xe, ys, ye]}) do + %{ + "@type" => "IntervalRelation", + "relation" => encode_value(r), + "start" => encode_value(xs), + "end" => encode_value(xe), + "start2" => encode_value(ys), + "end2" => encode_value(ye) + } + end + + def encode(%TerminusDB.WOQL{op: :interval_relation_typed, args: [r, x, y]}) do + %{ + "@type" => "IntervalRelationTyped", + "relation" => encode_value(r), + "left" => encode_value(x), + "right" => encode_value(y) + } + end + + def encode(%TerminusDB.WOQL{op: :date_duration, args: [s, e, d]}) do + %{ + "@type" => "DateDuration", + "start" => encode_value(s), + "end" => encode_value(e), + "duration" => encode_value(d) + } + end + + def encode(%TerminusDB.WOQL{op: :day_after, args: [d, n]}) do + %{ + "@type" => "DayAfter", + "date" => encode_value(d), + "next" => encode_value(n) + } + end + + def encode(%TerminusDB.WOQL{op: :day_before, args: [d, p]}) do + %{ + "@type" => "DayBefore", + "date" => encode_value(d), + "previous" => encode_value(p) + } + end + + def encode(%TerminusDB.WOQL{op: :weekday, args: [d, w]}) do + %{ + "@type" => "Weekday", + "date" => encode_value(d), + "weekday" => encode_value(w) + } + end + + def encode(%TerminusDB.WOQL{op: :weekday_sunday_start, args: [d, w]}) do + %{ + "@type" => "WeekdaySundayStart", + "date" => encode_value(d), + "weekday" => encode_value(w) + } + end + + def encode(%TerminusDB.WOQL{op: :iso_week, args: [d, y, w]}) do + %{ + "@type" => "IsoWeek", + "date" => encode_value(d), + "year" => encode_value(y), + "week" => encode_value(w) + } + end + + def encode(%TerminusDB.WOQL{op: :month_start_date, args: [ym, d]}) do + %{ + "@type" => "MonthStartDate", + "year_month" => encode_value(ym), + "date" => encode_value(d) + } + end + + def encode(%TerminusDB.WOQL{op: :month_end_date, args: [ym, d]}) do + %{ + "@type" => "MonthEndDate", + "year_month" => encode_value(ym), + "date" => encode_value(d) + } + end + + def encode(%TerminusDB.WOQL{op: :month_start_dates, args: [d, s, e]}) do + %{ + "@type" => "MonthStartDates", + "date" => encode_value(d), + "start" => encode_value(s), + "end" => encode_value(e) + } + end + + def encode(%TerminusDB.WOQL{op: :month_end_dates, args: [d, s, e]}) do + %{ + "@type" => "MonthEndDates", + "date" => encode_value(d), + "start" => encode_value(s), + "end" => encode_value(e) + } + end + + def encode(%TerminusDB.WOQL{op: :in_range, args: [v, s, e]}) do + %{ + "@type" => "InRange", + "value" => encode_value(v), + "start" => encode_value(s), + "end" => encode_value(e) + } + end + + def encode(%TerminusDB.WOQL{op: :sequence, args: [v, s, e, step, count]}) do + base = %{ + "@type" => "Sequence", + "value" => encode_value(v), + "start" => encode_value(s), + "end" => encode_value(e) + } + + base + |> maybe_put_encoded("step", step) + |> maybe_put_encoded("count", count) + end + + def encode(%TerminusDB.WOQL{op: :range_min, args: [lst, r]}) do + %{ + "@type" => "RangeMin", + "list" => encode_data_list(lst), + "min" => encode_value(r) + } + end + + def encode(%TerminusDB.WOQL{op: :range_max, args: [lst, r]}) do + %{ + "@type" => "RangeMax", + "list" => encode_data_list(lst), + "max" => encode_value(r) + } + end + + # -------------------------------------------------------------------------- + # CSV / IO + # -------------------------------------------------------------------------- + + def encode(%TerminusDB.WOQL{op: :get, args: [as_vars, resource]}) do + %{ + "@type" => "Get", + "columns" => as_vars, + "resource" => encode(resource) + } + end + + def encode(%TerminusDB.WOQL{op: :put, args: [as_vars, query, resource]}) do + %{ + "@type" => "Put", + "columns" => as_vars, + "query" => encode(query), + "resource" => encode(resource) + } + end + + def encode(%TerminusDB.WOQL{op: :file, args: [fpath, format]}) do + %{ + "@type" => "QueryResource", + "source" => %{"@type" => "FileResource", "file_name" => fpath}, + "format" => encode_format(format) + } + end + + def encode(%TerminusDB.WOQL{op: :remote, args: [uri, format]}) do + %{ + "@type" => "QueryResource", + "source" => %{"@type" => "RemoteResource", "url" => uri}, + "format" => encode_format(format) + } + end + + def encode(%TerminusDB.WOQL{op: :post, args: [fpath, format]}) do + %{ + "@type" => "QueryResource", + "source" => %{"@type" => "PostResource", "file_name" => fpath}, + "format" => encode_format(format) + } + end + + defp encode_format(format) do + %{ + "@type" => "Format", + "format_type" => %{"@type" => "xsd:string", "@value" => format} + } + end + + defp maybe_put_encoded(map, _key, nil), do: map + defp maybe_put_encoded(map, key, value), do: Map.put(map, key, encode_value(value)) + # -------------------------------------------------------------------------- # NodeValue — nodes/IRIs (subjects, predicates, identifiers) # -------------------------------------------------------------------------- @@ -581,6 +897,10 @@ defmodule TerminusDB.WOQL.Encoder do def encode_value(value) when is_map(value), do: value + def encode_value(values) when is_list(values) do + %{"@type" => "Value", "list" => Enum.map(values, &encode_value/1)} + end + # -------------------------------------------------------------------------- # Document — encode raw document maps as DictionaryTemplate (InsertDocument # / UpdateDocument). Variables pass through as Value. @@ -639,8 +959,22 @@ defmodule TerminusDB.WOQL.Encoder do %{"@type" => "DataValue", "data" => literal} end + def encode_data(values) when is_list(values) do + %{"@type" => "DataValue", "list" => Enum.map(values, &encode_data/1)} + end + def encode_data(value) when is_map(value), do: value + defp encode_data_list(var) when is_binary(var) and binary_part(var, 0, 2) == "v:" do + %{"@type" => "DataValue", "variable" => String.slice(var, 2..-1//1)} + end + + defp encode_data_list(values) when is_list(values) do + %{"@type" => "DataValue", "list" => Enum.map(values, &encode_data/1)} + end + + defp encode_data_list(value), do: encode_data(value) + # -------------------------------------------------------------------------- # ArithmeticValue — arithmetic operands # -------------------------------------------------------------------------- @@ -663,6 +997,10 @@ defmodule TerminusDB.WOQL.Encoder do def encode_arithmetic(%TerminusDB.WOQL{} = q), do: encode(q) + def encode_arithmetic(value) when is_binary(value) do + %{"@type" => "ArithmeticValue", "data" => %{"@type" => "xsd:string", "@value" => value}} + end + def encode_arithmetic(value) when is_map(value), do: value # -------------------------------------------------------------------------- diff --git a/lib/terminus_db/woql/rdf_list.ex b/lib/terminus_db/woql/rdf_list.ex new file mode 100644 index 0000000..602d03f --- /dev/null +++ b/lib/terminus_db/woql/rdf_list.ex @@ -0,0 +1,555 @@ +defmodule TerminusDB.WOQL.RDFList do + @moduledoc """ + RDF list library for TerminusDB. + + Provides 17 functions for manipulating RDF `rdf:List` structures using + WOQL primitives. Each function composes `TerminusDB.WOQL.Query` structs + using existing `WOQL.*` builders. + + Variable name collisions are avoided via an internal `localize/1` helper + that generates process-unique variable names using `:erlang.unique_integer/1`. + + ## Quick start + + import TerminusDB.WOQL + + # Get the first element of an rdf:List + query = and_([ + triple("v:List", "rdf:type", iri("rdf:List")), + TerminusDB.WOQL.RDFList.rdflist_peek("v:List", "v:First") + ]) + + # Get all elements as an array + query = TerminusDB.WOQL.RDFList.rdflist_list("v:List", "v:Array") + + """ + + alias TerminusDB.WOQL + + @type woql_var :: String.t() + @type woql_query :: WOQL.t() + @type vars :: %{atom() => woql_var()} + + @spec localize((vars() -> woql_query())) :: woql_query() + defp localize(fun) do + counter = :erlang.unique_integer([:positive]) + + vars = + Enum.reduce( + ~w(head tail rest first first2 last elem next next2 prev rest2 cell new_cell result length arr dec cell_a cell_b)a, + %{}, + fn name, acc -> + Map.put(acc, name, "v:RDFList_#{name}_#{counter}") + end + ) + + fun.(vars) + end + + @doc """ + Collects all rdf:List elements into a single array variable. + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_list("v:List", "v:Array") + iex> q.op + :and + + """ + @spec rdflist_list(woql_var(), woql_var()) :: woql_query() + def rdflist_list(cons_subject, list_var) do + localize(fn v -> + WOQL.and_([ + WOQL.path(cons_subject, "rdf:rest*", v.cell), + WOQL.group_by([v.cell], v.head, list_var, WOQL.triple(v.cell, "rdf:first", v.head)) + ]) + end) + end + + @doc """ + Gets the first element (rdf:first) of an rdf:List. + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_peek("v:List", "v:First") + iex> q.op + :triple + + """ + @spec rdflist_peek(woql_var(), woql_var()) :: woql_query() + def rdflist_peek(cons_subject, value_var) do + WOQL.triple(cons_subject, "rdf:first", value_var) + end + + @doc """ + Gets the last element of an rdf:List (the value of the cell whose rdf:rest is rdf:nil). + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_last("v:List", "v:Last") + iex> q.op + :and + + """ + @spec rdflist_last(woql_var(), woql_var()) :: woql_query() + def rdflist_last(cons_subject, value_var) do + localize(fn v -> + WOQL.and_([ + WOQL.path(cons_subject, "rdf:rest*", v.last), + WOQL.triple(v.last, "rdf:rest", WOQL.iri("rdf:nil")), + WOQL.triple(v.last, "rdf:first", value_var) + ]) + end) + end + + @doc """ + Gets the element at a 0-indexed position. + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_nth0("v:List", 2, "v:Elem") + iex> q.op + :and + + """ + @spec rdflist_nth0(woql_var(), non_neg_integer() | woql_var(), woql_var()) :: woql_query() + def rdflist_nth0(cons_subject, index, value_var) do + rdflist_nth(cons_subject, index, value_var, 0) + end + + @doc """ + Gets the element at a 1-indexed position. + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_nth1("v:List", 3, "v:Elem") + iex> q.op + :and + + """ + @spec rdflist_nth1(woql_var(), pos_integer() | woql_var(), woql_var()) :: woql_query() + def rdflist_nth1(cons_subject, index, value_var) do + rdflist_nth(cons_subject, index, value_var, 1) + end + + defp rdflist_nth(cons_subject, index, value_var, base) when is_integer(index) do + if index <= base do + WOQL.triple(cons_subject, "rdf:first", value_var) + else + localize(fn v -> + WOQL.and_([ + WOQL.triple(cons_subject, "rdf:rest", v.rest), + rdflist_nth(v.rest, index - 1, value_var, base) + ]) + end) + end + end + + defp rdflist_nth(cons_subject, index_var, value_var, _base) when is_binary(index_var) do + localize(fn v -> + WOQL.and_([ + WOQL.triple(cons_subject, "rdf:first", v.first), + WOQL.triple(cons_subject, "rdf:rest", v.rest), + WOQL.or_([ + WOQL.and_([ + WOQL.eq(index_var, 0), + WOQL.eq(value_var, v.first) + ]), + WOQL.and_([ + WOQL.greater(index_var, 0), + WOQL.eval(WOQL.minus([index_var, 1]), v.dec), + WOQL.triple(v.rest, "rdf:first", v.first2), + WOQL.triple(v.rest, "rdf:rest", v.rest2), + WOQL.or_([ + WOQL.and_([ + WOQL.eq(v.dec, 0), + WOQL.eq(value_var, v.first2) + ]), + WOQL.and_([ + WOQL.greater(v.dec, 0), + WOQL.eq(value_var, v.first2) + ]) + ]) + ]) + ]) + ]) + end) + end + + @doc """ + Traverses the list, yielding each element as a separate binding. + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_member("v:List", "v:Elem") + iex> q.op + :and + + """ + @spec rdflist_member(woql_var(), woql_var()) :: woql_query() + def rdflist_member(cons_subject, value) do + localize(fn v -> + WOQL.and_([ + WOQL.path(cons_subject, "rdf:rest*", v.cell), + WOQL.triple(v.cell, "rdf:first", value) + ]) + end) + end + + @doc """ + Gets the length of an rdf:List. + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_length("v:List", "v:Len") + iex> q.op + :and + + """ + @spec rdflist_length(woql_var(), woql_var()) :: woql_query() + def rdflist_length(cons_subject, length_var) do + localize(fn v -> + WOQL.and_([ + WOQL.path(cons_subject, "rdf:rest*", v.cell), + WOQL.triple(v.cell, "rdf:rest", WOQL.iri("rdf:nil")), + WOQL.length([v.cell], length_var) + ]) + end) + end + + @doc """ + Pops the first element in-place (deletes the head cell). + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_pop("v:List", "v:Value") + iex> q.op + :and + + """ + @spec rdflist_pop(woql_var(), woql_var()) :: woql_query() + def rdflist_pop(cons_subject, value_var) do + localize(fn v -> + WOQL.and_([ + WOQL.triple(cons_subject, "rdf:first", value_var), + WOQL.triple(cons_subject, "rdf:rest", v.rest), + WOQL.delete_triple(cons_subject, "rdf:first", value_var), + WOQL.delete_triple(cons_subject, "rdf:rest", v.rest) + ]) + end) + end + + @doc """ + Pushes a value to the front of the list, returning the new head cell. + + The caller must update their reference to the new head cell. The original + list is not modified — the new cell's `rdf:rest` points to the original + list head. + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_push("v:List", "v:Value", "v:NewHead") + iex> q.op + :and + + """ + @spec rdflist_push(woql_var(), woql_var(), woql_var()) :: woql_query() + def rdflist_push(cons_subject, value, new_head_var) do + WOQL.and_([ + WOQL.idgen_random("rdf:List", new_head_var), + WOQL.add_triple(new_head_var, "rdf:first", value), + WOQL.add_triple(new_head_var, "rdf:rest", cons_subject) + ]) + end + + @doc """ + Appends a value to the end of the list (allocates a new cell at rdf:nil). + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_append("v:List", "v:Value") + iex> q.op + :and + + """ + @spec rdflist_append(woql_var(), woql_var(), woql_var() | nil) :: woql_query() + def rdflist_append(cons_subject, value, new_cell \\ nil) do + localize(fn v -> + cell = new_cell || v.new_cell + + WOQL.and_([ + WOQL.path(cons_subject, "rdf:rest*", v.last), + WOQL.triple(v.last, "rdf:rest", WOQL.iri("rdf:nil")), + WOQL.idgen_random("rdf:List", cell), + WOQL.add_triple(cell, "rdf:first", value), + WOQL.add_triple(cell, "rdf:rest", WOQL.iri("rdf:nil")), + WOQL.delete_triple(v.last, "rdf:rest", WOQL.iri("rdf:nil")), + WOQL.add_triple(v.last, "rdf:rest", cell) + ]) + end) + end + + @doc """ + Deletes all cons cells and returns rdf:nil as the new list value. + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_clear("v:List", "v:NewList") + iex> q.op + :and + + """ + @spec rdflist_clear(woql_var(), woql_var()) :: woql_query() + def rdflist_clear(cons_subject, new_list_var) do + localize(fn v -> + WOQL.and_([ + WOQL.path(cons_subject, "rdf:rest*", v.cell), + WOQL.opt( + WOQL.and_([ + WOQL.triple(v.cell, "rdf:first", v.first), + WOQL.delete_triple(v.cell, "rdf:first", v.first) + ]) + ), + WOQL.opt( + WOQL.and_([ + WOQL.triple(v.cell, "rdf:rest", v.rest), + WOQL.delete_triple(v.cell, "rdf:rest", v.rest) + ]) + ), + WOQL.eq(new_list_var, WOQL.iri("rdf:nil")) + ]) + end) + end + + @doc """ + Creates an empty rdf:List (binds to rdf:nil). + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_empty("v:List") + iex> q.op + :eq + + """ + @spec rdflist_empty(woql_var()) :: woql_query() + def rdflist_empty(list_var) do + WOQL.eq(list_var, WOQL.iri("rdf:nil")) + end + + @doc """ + Checks if the list is empty (equals rdf:nil). + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_is_empty("v:List") + iex> q.op + :eq + + """ + @spec rdflist_is_empty(woql_var()) :: woql_query() + def rdflist_is_empty(cons_subject) do + WOQL.eq(cons_subject, WOQL.iri("rdf:nil")) + end + + @doc """ + Extracts a slice [start, end) as an array. + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_slice("v:List", 0, 3, "v:Result") + iex> q.op + :and + + """ + @spec rdflist_slice( + woql_var(), + non_neg_integer() | woql_var(), + non_neg_integer() | woql_var(), + woql_var() + ) :: woql_query() + def rdflist_slice(cons_subject, start, end_val, result_var) + when is_integer(start) and is_integer(end_val) do + # Navigate to the cell at `start`, then collect elements up to `end` + slice_from(cons_subject, 0, start, end_val, result_var) + end + + def rdflist_slice(cons_subject, start, end_val, result_var) do + localize(fn v -> + WOQL.and_([ + WOQL.path(cons_subject, "rdf:rest*", v.cell), + WOQL.triple(v.cell, "rdf:first", v.elem), + WOQL.collect(v.elem, result_var, rdflist_member(cons_subject, v.elem)), + WOQL.gte(v.elem, start), + WOQL.less(v.elem, end_val) + ]) + end) + end + + defp slice_from(_cell, count, _start, end_val, result_var) + when is_integer(count) and is_integer(end_val) and count >= end_val do + WOQL.collect("v:slice_elem", result_var, WOQL.true_()) + end + + defp slice_from(cell, count, start, end_val, result_var) when count < start do + localize(fn v -> + WOQL.and_([ + WOQL.triple(cell, "rdf:rest", v.next), + slice_from(v.next, count + 1, start, end_val, result_var) + ]) + end) + end + + defp slice_from(cell, count, start, end_val, result_var) when count >= start do + localize(fn v -> + WOQL.and_([ + WOQL.triple(cell, "rdf:first", v.elem), + slice_collect(cell, count, end_val, v.elem, result_var) + ]) + end) + end + + defp slice_collect(_cell, count, end_val, elem_var, result_var) + when is_integer(count) and count >= end_val do + WOQL.collect(elem_var, result_var, WOQL.true_()) + end + + defp slice_collect(cell, count, end_val, _elem_var, result_var) do + localize(fn v -> + WOQL.and_([ + WOQL.triple(cell, "rdf:rest", v.next), + WOQL.triple(v.next, "rdf:first", v.elem), + slice_collect(v.next, count + 1, end_val, v.elem, result_var) + ]) + end) + end + + @doc """ + Inserts a value at a 0-indexed position (allocates a new cell). + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_insert("v:List", 1, "v:Value") + iex> q.op + :and + + """ + @spec rdflist_insert( + woql_var(), + non_neg_integer() | woql_var(), + woql_var(), + woql_var() | nil + ) :: woql_query() + def rdflist_insert(cons_subject, position, value, new_cell \\ nil) do + localize(fn v -> + cell = new_cell || v.new_cell + + WOQL.and_([ + WOQL.idgen_random("rdf:List", cell), + WOQL.add_triple(cell, "rdf:first", value), + rdflist_insert_at(cons_subject, position, cell) + ]) + end) + end + + defp rdflist_insert_at(cons_subject, 0, new_cell) do + localize(fn v -> + WOQL.and_([ + WOQL.triple(cons_subject, "rdf:rest", v.rest), + WOQL.add_triple(new_cell, "rdf:rest", v.rest), + WOQL.delete_triple(cons_subject, "rdf:rest", v.rest), + WOQL.add_triple(cons_subject, "rdf:rest", new_cell) + ]) + end) + end + + defp rdflist_insert_at(cons_subject, position, new_cell) + when is_integer(position) and position > 0 do + localize(fn v -> + WOQL.and_([ + WOQL.triple(cons_subject, "rdf:rest", v.rest), + rdflist_insert_at(v.rest, position - 1, new_cell) + ]) + end) + end + + @doc """ + Drops/removes a single element at the given position. + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_drop("v:List", 1) + iex> q.op + :and + + """ + @spec rdflist_drop(woql_var(), non_neg_integer() | woql_var()) :: woql_query() + def rdflist_drop(cons_subject, 0) do + localize(fn v -> + WOQL.and_([ + WOQL.triple(cons_subject, "rdf:first", v.elem), + WOQL.triple(cons_subject, "rdf:rest", v.rest), + WOQL.delete_triple(cons_subject, "rdf:first", v.elem), + WOQL.delete_triple(cons_subject, "rdf:rest", v.rest) + ]) + end) + end + + def rdflist_drop(cons_subject, position) when is_integer(position) and position > 0 do + localize(fn v -> + WOQL.and_([ + rdflist_cell_at(cons_subject, position, v.cell), + rdflist_cell_at(cons_subject, position - 1, v.prev), + WOQL.triple(v.cell, "rdf:first", v.elem), + WOQL.triple(v.cell, "rdf:rest", v.rest), + WOQL.delete_triple(v.cell, "rdf:first", v.elem), + WOQL.delete_triple(v.cell, "rdf:rest", v.rest), + WOQL.delete_triple(v.prev, "rdf:rest", v.cell), + WOQL.add_triple(v.prev, "rdf:rest", v.rest) + ]) + end) + end + + @doc """ + Swaps elements at two positions. + + ## Examples + + iex> q = TerminusDB.WOQL.RDFList.rdflist_swap("v:List", 0, 2) + iex> q.op + :and + + """ + @spec rdflist_swap( + woql_var(), + non_neg_integer() | woql_var(), + non_neg_integer() | woql_var() + ) :: woql_query() + def rdflist_swap(cons_subject, pos_a, pos_b) do + localize(fn v -> + WOQL.and_([ + rdflist_nth0(cons_subject, pos_a, v.first), + rdflist_nth0(cons_subject, pos_b, v.last), + rdflist_cell_at(cons_subject, pos_a, v.cell_a), + rdflist_cell_at(cons_subject, pos_b, v.cell_b), + WOQL.delete_triple(v.cell_a, "rdf:first", v.first), + WOQL.delete_triple(v.cell_b, "rdf:first", v.last), + WOQL.add_triple(v.cell_a, "rdf:first", v.last), + WOQL.add_triple(v.cell_b, "rdf:first", v.first) + ]) + end) + end + + defp rdflist_cell_at(cons_subject, 0, cell_var) do + WOQL.eq(cell_var, cons_subject) + end + + defp rdflist_cell_at(cons_subject, position, cell_var) + when is_integer(position) and position > 0 do + localize(fn v -> + WOQL.and_([ + WOQL.triple(cons_subject, "rdf:rest", v.rest), + rdflist_cell_at(v.rest, position - 1, cell_var) + ]) + end) + end +end diff --git a/mix.exs b/mix.exs index 2fb65ed..5e0be58 100644 --- a/mix.exs +++ b/mix.exs @@ -1,13 +1,13 @@ defmodule TerminusDB.MixProject do use Mix.Project - @version "0.3.1" + @version "0.3.2" @source_url "https://github.com/thanos/terminusdb-client-elixir" @description """ - An idiomatic Elixir client for TerminusDB, the version-controlled document graph - database. Features DB management, document CRUD with streaming, schema frames, - branching, commit history, diff/merge, a WOQL query DSL, a Req-based HTTP client, - immutable configs, typed errors, and telemetry. + An idiomatic Elixir client for TerminusDB, the version-controlled document + graph database. Features DB management, document CRUD with streaming, schema + frames, branching, diff/merge, a ~100-operator WOQL DSL, GraphQL, and + telemetry. Built on Req with immutable configs and typed errors. """ def project do @@ -67,7 +67,9 @@ defmodule TerminusDB.MixProject do {:dialyxir, "~> 1.4", only: :dev, runtime: false}, {:sobelow, "~> 0.13", only: :dev, runtime: false}, {:excoveralls, "~> 0.18", only: :test, runtime: false}, - {:stream_data, "~> 1.1", only: [:test, :dev]} + {:stream_data, "~> 1.1", only: [:test, :dev]}, + {:benchee, "~> 1.3", only: :dev, runtime: false}, + {:benchee_html, "~> 1.0", only: :dev, runtime: false} ] end @@ -113,12 +115,26 @@ defmodule TerminusDB.MixProject do {"guides/introduction.md", [title: "Introduction to TerminusDB"]}, {"guides/migrating-from-sql.md", [title: "Migrating from SQL"]}, {"guides/overview.md", [title: "Overview Guide"]}, + {"guides/graphql-guide.md", [title: "GraphQL Guide"]}, + {"guides/temporal-allen-guide.md", [title: "Temporal & Allen Interval Algebra"]}, + {"guides/csv-import-guide.md", [title: "CSV Import / Export"]}, + {"guides/rdf-list-guide.md", [title: "RDF List Library"]}, + {"guides/terminusdb_ex_livebook.livemd", [title: "Livebook Demo"]}, {"CHANGELOG.md", [title: "Changelog"]}, {"ARCHITECTURE.md", [title: "Architecture"]}, {"LICENSE", [title: "License"]} ], groups_for_extras: [ - Guides: ["guides/introduction.md", "guides/migrating-from-sql.md", "guides/overview.md"], + Guides: [ + "guides/introduction.md", + "guides/migrating-from-sql.md", + "guides/overview.md", + "guides/graphql-guide.md", + "guides/temporal-allen-guide.md", + "guides/csv-import-guide.md", + "guides/rdf-list-guide.md", + "guides/terminusdb_ex_livebook.livemd" + ], Architecture: ["ARCHITECTURE.md"] ], source_ref: "v#{@version}", diff --git a/mix.lock b/mix.lock index a88b62a..d7d40da 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,10 @@ %{ + "benchee": {:hex, :benchee, "1.5.1", "b95cbc36c4b98969a5c592a246e171041eb683c56bad1cb4f49a3b081ba66087", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a539301f8dfd4efc5c5123bfb9d47ebde20092a863a5b5b16c2a60d2243dfce7"}, + "benchee_html": {:hex, :benchee_html, "1.0.1", "1e247c0886c3fdb0d3f4b184b653a8d6fb96e4ad0d0389267fe4f36968772e24", [:mix], [{:benchee, ">= 0.99.0 and < 2.0.0", [hex: :benchee, repo: "hexpm", optional: false]}, {:benchee_json, "~> 1.0", [hex: :benchee_json, repo: "hexpm", optional: false]}], "hexpm", "b00a181af7152431901e08f3fc9f7197ed43ff50421a8347b0c80bf45d5b3fef"}, + "benchee_json": {:hex, :benchee_json, "1.0.0", "cc661f4454d5995c08fe10dd1f2f72f229c8f0fb1c96f6b327a8c8fc96a91fe5", [:mix], [{:benchee, ">= 0.99.0 and < 2.0.0", [hex: :benchee, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "da05d813f9123505f870344d68fb7c86a4f0f9074df7d7b7e2bb011a63ec231c"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, + "deep_merge": {:hex, :deep_merge, "1.0.2", "476aa7ea61c54de96220051b998d893869069094da65b96101aebf79416f8a1e", [:mix], [], "hexpm", "737a53cdc9758fedbb608bdc213969e65729466c4ef3cd8e8726d0335dff116c"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.45", "cba8369ab2a1342e419bc2760eec731b17be828941dcf494045d44766227e1d5", [:mix], [], "hexpm", "d3ec045bf122965db20c0bdb420e19ee1415843135327124918473feb4b328e8"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, @@ -20,6 +24,7 @@ "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, + "statistex": {:hex, :statistex, "1.1.1", "73612aa7f79e53c30569be065fd121e380f1cf57bc4c2da5b41be9246da18df9", [:mix], [], "hexpm", "310c4b49b34adf683de3103639006bed233ab54c08a4add65a531448e653857c"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, } diff --git a/test/integration/graphql_test.exs b/test/integration/graphql_test.exs new file mode 100644 index 0000000..60fea40 --- /dev/null +++ b/test/integration/graphql_test.exs @@ -0,0 +1,116 @@ +defmodule TerminusDB.Integration.GraphQLTest do + use ExUnit.Case, async: false + + @moduletag :integration + + alias TerminusDB.{Config, Database, Document, GraphQL} + + defp config do + endpoint = System.get_env("TERMINUSDB_URL", "http://localhost:6363") + user = System.get_env("TERMINUSDB_USER", "admin") + key = System.get_env("TERMINUSDB_KEY", "root") + + Config.new(endpoint: endpoint, user: user, key: key) + end + + setup do + cfg = config() + db = "int_gql_#{:erlang.unique_integer([:positive])}" + Database.create!(cfg, db, label: "GraphQL Test", schema: true) + scoped = Config.with_database(cfg, db) + + Document.insert!( + scoped, + %{ + "@type" => "Class", + "@id" => "Person", + "@key" => %{"@type" => "Lexical", "@fields" => ["name"]}, + "name" => "xsd:string", + "age" => "xsd:integer" + }, + author: "admin", + message: "add schema", + graph_type: :schema + ) + + Document.insert!( + scoped, + %{"@type" => "Person", "name" => "Alice", "age" => 30}, + author: "admin", + message: "add Alice" + ) + + on_exit(fn -> + Database.delete(cfg, db, force: true) + end) + + {:ok, config: scoped} + end + + describe "query" do + test "queries Person documents", %{config: cfg} do + assert {:ok, result} = GraphQL.query(cfg, "{ Person { name age } }") + assert result.errors == nil + persons = result.data["Person"] + assert is_list(persons) + assert length(persons) == 1 + assert hd(persons)["name"] == "Alice" + end + + test "queries with filter", %{config: cfg} do + query = "{ Person(filter: {name: {eq: \"Alice\"}}) { name } }" + assert {:ok, result} = GraphQL.query(cfg, query) + + assert result.errors == nil + assert length(result.data["Person"]) == 1 + end + + test "queries with limit", %{config: cfg} do + assert {:ok, result} = GraphQL.query(cfg, "{ Person(limit: 1) { name } }") + assert result.errors == nil + assert length(result.data["Person"]) == 1 + end + + test "returns errors on invalid query", %{config: cfg} do + assert {:ok, result} = GraphQL.query(cfg, "{ NonExistent { name } }") + assert result.errors != nil + end + end + + describe "introspect" do + test "returns schema types", %{config: cfg} do + assert {:ok, schema} = GraphQL.introspect(cfg) + assert schema["__schema"] + types = schema["__schema"]["types"] + assert is_list(types) + type_names = Enum.map(types, & &1["name"]) + assert "Person" in type_names + end + end + + describe "mutate" do + test "inserts a document via mutation", %{config: cfg} do + doc_json = Jason.encode!(%{"@type" => "Person", "name" => "Bob", "age" => 25}) + escaped = String.replace(doc_json, "\\", "\\\\") + escaped = String.replace(escaped, "\"", "\\\"") + mutation = "mutation { _insertDocuments(json: \"#{escaped}\") }" + + case GraphQL.mutate(cfg, mutation) do + {:ok, result} -> + if result.errors do + # Some TerminusDB versions may not support all GraphQL mutation features + assert is_list(result.errors) + else + {:ok, result2} = + GraphQL.query(cfg, "{ Person(filter: {name: {eq: \"Bob\"}}) { name } }") + + assert length(result2.data["Person"]) == 1 + end + + {:error, _} -> + # GraphQL mutations may not be available on all TDB versions + :ok + end + end + end +end diff --git a/test/integration/patch_diff_test.exs b/test/integration/patch_diff_test.exs new file mode 100644 index 0000000..a466e6f --- /dev/null +++ b/test/integration/patch_diff_test.exs @@ -0,0 +1,102 @@ +defmodule TerminusDB.Integration.PatchDiffTest do + use ExUnit.Case, async: false + + @moduletag :integration + + alias TerminusDB.{Config, Database, Diff, Document, Patch} + + defp config do + endpoint = System.get_env("TERMINUSDB_URL", "http://localhost:6363") + user = System.get_env("TERMINUSDB_USER", "admin") + key = System.get_env("TERMINUSDB_KEY", "root") + + Config.new(endpoint: endpoint, user: user, key: key) + end + + setup do + cfg = config() + db = "int_patch_#{:erlang.unique_integer([:positive])}" + Database.create!(cfg, db, label: "Patch Test", schema: true) + scoped = Config.with_database(cfg, db) + + Document.insert!( + scoped, + %{ + "@type" => "Class", + "@id" => "Person", + "@key" => %{"@type" => "Lexical", "@fields" => ["name"]}, + "name" => "xsd:string", + "age" => "xsd:integer" + }, + author: "admin", + message: "add schema", + graph_type: :schema + ) + + Document.insert!( + scoped, + %{"@type" => "Person", "name" => "Alice", "age" => 30}, + author: "admin", + message: "add Alice" + ) + + on_exit(fn -> + Database.delete(cfg, db, force: true) + end) + + {:ok, config: scoped} + end + + describe "diff_object" do + test "diffs two documents and returns Patch", %{config: cfg} do + assert {:ok, %Patch{} = patch} = + Diff.diff_object(cfg, + before: %{"@id" => "Person/Alice", "name" => "Alice", "age" => 30}, + after: %{"@id" => "Person/Alice", "name" => "Alicia", "age" => 31} + ) + + assert is_map(patch.content) + end + end + + describe "patch" do + test "applies a patch to a before object", %{config: cfg} do + {:ok, %Patch{} = patch} = + Diff.diff_object(cfg, + before: %{"@id" => "Person/Alice", "name" => "Alice"}, + after: %{"@id" => "Person/Alice", "name" => "Alicia"} + ) + + assert {:ok, result} = + Diff.patch(cfg, + before: %{"@id" => "Person/Alice", "name" => "Alice"}, + patch: patch.content + ) + + assert is_map(result) + end + end + + describe "Commit.document_history" do + test "returns history for a document", %{config: cfg} do + alias TerminusDB.Commit + assert {:ok, history} = Commit.document_history(cfg, id: "Person/Alice", count: 10) + assert is_list(history) + assert history != [] + end + end + + describe "Branch.squash and reset" do + test "squash compresses branch history", %{config: cfg} do + alias TerminusDB.Branch + assert {:ok, _} = Branch.squash(cfg, author: "admin", message: "squash") + end + end + + describe "Database.optimize" do + test "optimizes a branch", %{config: cfg} do + alias TerminusDB.Database + assert {:ok, _} = Database.optimize(cfg, "admin/#{cfg.database}/local/branch/main") + end + end +end diff --git a/test/integration/prefix_test.exs b/test/integration/prefix_test.exs new file mode 100644 index 0000000..5597448 --- /dev/null +++ b/test/integration/prefix_test.exs @@ -0,0 +1,57 @@ +defmodule TerminusDB.Integration.PrefixTest do + use ExUnit.Case, async: false + + @moduletag :integration + + alias TerminusDB.{Config, Database, Prefix} + + defp config do + endpoint = System.get_env("TERMINUSDB_URL", "http://localhost:6363") + user = System.get_env("TERMINUSDB_USER", "admin") + key = System.get_env("TERMINUSDB_KEY", "root") + + Config.new(endpoint: endpoint, user: user, key: key) + end + + setup do + cfg = config() + db = "int_prefix_#{:erlang.unique_integer([:positive])}" + Database.create!(cfg, db, label: "Prefix Test", schema: true) + scoped = Config.with_database(cfg, db) + + on_exit(fn -> + Database.delete(cfg, db, force: true) + end) + + {:ok, config: scoped} + end + + describe "prefix CRUD" do + test "add, get, update, delete a prefix", %{config: cfg} do + assert {:ok, _} = Prefix.add(cfg, "ex", "http://example.org/") + + assert {:ok, uri} = Prefix.get(cfg, "ex") + assert uri == "http://example.org/" + + assert {:ok, _} = Prefix.update(cfg, "ex", "http://example.com/") + assert {:ok, uri2} = Prefix.get(cfg, "ex") + assert uri2 == "http://example.com/" + + assert {:ok, _} = Prefix.delete(cfg, "ex") + assert {:error, _} = Prefix.get(cfg, "ex") + end + + test "upsert creates or updates", %{config: cfg} do + assert {:ok, _} = Prefix.upsert(cfg, "test", "http://test.org/") + assert {:ok, _} = Prefix.upsert(cfg, "test", "http://test2.org/") + assert {:ok, uri} = Prefix.get(cfg, "test") + assert uri == "http://test2.org/" + end + + test "all returns default prefixes", %{config: cfg} do + assert {:ok, prefixes} = Prefix.all(cfg) + assert prefixes["@base"] + assert prefixes["@schema"] + end + end +end diff --git a/test/integration/triples_test.exs b/test/integration/triples_test.exs new file mode 100644 index 0000000..6972cf3 --- /dev/null +++ b/test/integration/triples_test.exs @@ -0,0 +1,59 @@ +defmodule TerminusDB.Integration.TriplesTest do + use ExUnit.Case, async: false + + @moduletag :integration + + alias TerminusDB.{Config, Database, Triples} + + defp config do + endpoint = System.get_env("TERMINUSDB_URL", "http://localhost:6363") + user = System.get_env("TERMINUSDB_USER", "admin") + key = System.get_env("TERMINUSDB_KEY", "root") + + Config.new(endpoint: endpoint, user: user, key: key) + end + + setup do + cfg = config() + db = "int_triples_#{:erlang.unique_integer([:positive])}" + Database.create!(cfg, db, label: "Triples Test", schema: true) + scoped = Config.with_database(cfg, db) + + on_exit(fn -> + Database.delete(cfg, db, force: true) + end) + + {:ok, config: scoped} + end + + describe "triples" do + test "get schema triples returns turtle", %{config: cfg} do + assert {:ok, turtle} = Triples.get(cfg, graph_type: :schema) + assert is_binary(turtle) + end + + test "get instance triples returns turtle", %{config: cfg} do + assert {:ok, turtle} = Triples.get(cfg, graph_type: :instance) + assert is_binary(turtle) + end + + test "insert and update triples", %{config: cfg} do + turtle = + "@prefix xsd: . \"baz\"^^xsd:string ." + + assert {:ok, _} = + Triples.insert(cfg, turtle, + graph_type: :instance, + author: "admin", + message: "insert" + ) + + assert {:ok, _} = + Triples.update(cfg, turtle, + graph_type: :instance, + author: "admin", + message: "replace" + ) + end + end +end diff --git a/test/integration/versioned_workflows_test.exs b/test/integration/versioned_workflows_test.exs index f8618fe..5c502b4 100644 --- a/test/integration/versioned_workflows_test.exs +++ b/test/integration/versioned_workflows_test.exs @@ -103,14 +103,32 @@ defmodule TerminusDB.Integration.VersionedWorkflowsTest do message: "add on feature" ) - # Merge feature into main - {:ok, result} = Merge.merge(cfg, source_branch: "merge_test", target_branch: "main") + # Add a different document on main (diverge) + Document.insert!( + cfg, + %{"@type" => "Thing", "name" => "from-main"}, + author: "admin", + message: "add on main" + ) + + # Merge feature into main (retry — TerminusDB rebase can be flaky) + result = + Enum.reduce_while(1..3, {:error, nil}, fn _i, _acc -> + case Merge.merge(cfg, source_branch: "merge_test", target_branch: "main") do + {:ok, res} -> {:halt, {:ok, res}} + {:error, %TerminusDB.Error{status: 500}} -> {:cont, {:error, nil}} + {:error, e} -> {:halt, {:error, e}} + end + end) + + {:ok, result} = result assert result["api:status"] == "api:success" # The feature document should now be on main {:ok, docs} = Document.get(cfg, type: "Thing", as_list: true) names = Enum.map(docs, & &1["name"]) assert "from-feature" in names + assert "from-main" in names # Cleanup {:ok, _} = Branch.delete(cfg, "merge_test") diff --git a/test/integration/woql_test.exs b/test/integration/woql_test.exs index 04a631e..193d878 100644 --- a/test/integration/woql_test.exs +++ b/test/integration/woql_test.exs @@ -333,4 +333,149 @@ defmodule TerminusDB.Integration.WOQLTest do assert "Person/Bob" in targets or "terminusdb:///data/Person/Bob" in targets end end + + describe "range queries" do + test "triple_slice returns values in range", %{config: cfg} do + insert_schema(cfg) + insert_person(cfg, "Alice", 30) + insert_person(cfg, "Bob", 25) + insert_person(cfg, "Carol", 40) + + query = + WOQL.select( + ["v:Name", "v:Age"], + WOQL.and_([ + WOQL.triple("v:P", "name", "v:Name"), + WOQL.triple("v:P", "age", "v:Age"), + WOQL.triple("v:P", "rdf:type", WOQL.iri("@schema:Person")), + WOQL.triple_slice("v:P", "age", "v:Age", 20, 35) + ]) + ) + + {:ok, result} = WOQL.execute(cfg, query) + assert result["api:status"] == "api:success" + names = binding_values(result["bindings"], "Name") + assert "Alice" in names + assert "Bob" in names + refute "Carol" in names + end + + test "triple_next finds next value after reference", %{config: cfg} do + insert_schema(cfg) + insert_person(cfg, "Alice", 30) + insert_person(cfg, "Bob", 25) + insert_person(cfg, "Carol", 40) + + query = + WOQL.select( + ["v:Next"], + WOQL.and_([ + WOQL.triple("v:P", "age", 25), + WOQL.triple_next("v:P", "age", 25, "v:Next") + ]) + ) + + {:ok, result} = WOQL.execute(cfg, query) + assert result["api:status"] == "api:success" + end + end + + describe "temporal / Allen" do + test "in_range checks if value is in range", %{config: cfg} do + insert_schema(cfg) + insert_person(cfg, "Alice", 30) + + query = + WOQL.select( + ["v:Name"], + WOQL.and_([ + WOQL.triple("v:P", "name", "v:Name"), + WOQL.triple("v:P", "age", "v:Age"), + WOQL.in_range("v:Age", 20, 40) + ]) + ) + + {:ok, result} = WOQL.execute(cfg, query) + assert result["api:status"] == "api:success" + names = binding_values(result["bindings"], "Name") + assert "Alice" in names + end + + test "day_after computes next day", %{config: cfg} do + insert_schema(cfg) + + query = + WOQL.select( + ["v:Next"], + WOQL.day_after(WOQL.date("2026-01-15"), "v:Next") + ) + + {:ok, result} = WOQL.execute(cfg, query) + assert result["api:status"] == "api:success" + next_days = binding_values(result["bindings"], "Next") + assert "2026-01-16" in next_days + end + + test "weekday computes ISO weekday", %{config: cfg} do + insert_schema(cfg) + + query = + WOQL.select( + ["v:Day"], + WOQL.weekday("2026-01-15", "v:Day") + ) + + {:ok, result} = WOQL.execute(cfg, query) + assert result["api:status"] == "api:success" + end + + test "range_min encodes and executes", %{config: cfg} do + insert_schema(cfg) + insert_person(cfg, "Alice", 30) + + query = + WOQL.select( + ["v:Min"], + WOQL.range_min("v:Ages", "v:Min") + ) + + result = WOQL.execute(cfg, query) + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + + test "range_max encodes and executes", %{config: cfg} do + insert_schema(cfg) + insert_person(cfg, "Alice", 30) + + query = + WOQL.select( + ["v:Max"], + WOQL.range_max("v:Ages", "v:Max") + ) + + result = WOQL.execute(cfg, query) + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + end + + describe "WOQL streaming" do + test "execute_stream returns a stream of bindings", %{config: cfg} do + insert_schema(cfg) + insert_person(cfg, "Alice", 30) + insert_person(cfg, "Bob", 25) + + query = + WOQL.select( + ["v:Name"], + WOQL.and_([ + WOQL.triple("v:P", "name", "v:Name"), + WOQL.triple("v:P", "rdf:type", WOQL.iri("@schema:Person")) + ]) + ) + + {:ok, stream} = WOQL.execute_stream(cfg, query) + bindings = Enum.to_list(stream) + assert length(bindings) >= 2 + end + end end diff --git a/test/terminus_db/branch_test.exs b/test/terminus_db/branch_test.exs index 3009e08..edecce2 100644 --- a/test/terminus_db/branch_test.exs +++ b/test/terminus_db/branch_test.exs @@ -134,4 +134,45 @@ defmodule TerminusDB.BranchTest do assert_raise Error, fn -> Branch.exists?(db_config(adapter), "feature") end end end + + describe "squash/2" do + test "POSTs to squash endpoint with commit_info" do + test = self() + adapter = capture(test, ok(%{"api:status" => "api:success", "api:commit" => "abc"})) + + assert {:ok, resp} = Branch.squash(db_config(adapter), author: "admin", message: "squash") + assert resp["api:commit"] == "abc" + + req = last_request() + assert req.method == :post + assert req.url.path == "/api/squash/admin/mydb/local/branch/main" + end + + test "squash! returns response or raises" do + adapter = fn req -> {req, Req.Response.new(status: 200, body: %{"api:commit" => "abc"})} end + assert Branch.squash!(db_config(adapter))["api:commit"] == "abc" + end + end + + describe "reset/3" do + test "POSTs to reset endpoint with commit_descriptor" do + test = self() + adapter = capture(test, ok(%{"api:status" => "api:success"})) + + assert {:ok, resp} = Branch.reset(db_config(adapter), "admin/mydb/local/commit/abc") + assert resp["api:status"] == "api:success" + + req = last_request() + assert req.method == :post + assert req.url.path == "/api/reset/admin/mydb/local/branch/main" + end + + test "reset! returns response or raises" do + adapter = fn req -> + {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} + end + + assert Branch.reset!(db_config(adapter), "commit/abc")["api:status"] == "api:success" + end + end end diff --git a/test/terminus_db/commit_test.exs b/test/terminus_db/commit_test.exs index 815b936..3715352 100644 --- a/test/terminus_db/commit_test.exs +++ b/test/terminus_db/commit_test.exs @@ -120,4 +120,66 @@ defmodule TerminusDB.CommitTest do assert_raise Error, fn -> Commit.get!(db_config(adapter), "commit/missing") end end end + + describe "document_history/3" do + test "GETs history/:org/:db with id param" do + test = self() + + history = [ + %{"author" => "admin", "identifier" => "abc", "message" => "Created"} + ] + + adapter = capture(test, Req.Response.new(status: 200, body: history)) + + assert {:ok, ^history} = + Commit.document_history(db_config(adapter), id: "Person/Alice", count: 5) + + req = last_request() + assert req.method == :get + assert req.url.path == "/api/history/admin/mydb" + assert req.url.query =~ "id=Person" + assert req.url.query =~ "count=5" + end + + test "includes created and updated flags when provided" do + test = self() + adapter = capture(test, ok([])) + + Commit.document_history(db_config(adapter), + id: "Person/Alice", + created: true, + updated: false + ) + + req = last_request() + assert req.url.query =~ "created=true" + assert req.url.query =~ "updated=false" + end + + test "document_history! returns list or raises" do + adapter = fn req -> {req, Req.Response.new(status: 200, body: [%{"author" => "admin"}])} end + + assert Commit.document_history!(db_config(adapter), id: "Person/Alice") == [ + %{"author" => "admin"} + ] + end + + test "document_history! raises on error" do + adapter = fn req -> {req, resp(500, %{"api:message" => "fail"})} end + + assert_raise Error, fn -> + Commit.document_history!(db_config(adapter), id: "Person/Alice") + end + end + + test "raises when no database is scoped" do + config = Config.new(endpoint: "http://localhost:6363", adapter: fn r -> {r, ok()} end) + assert_raise Error, fn -> Commit.document_history(config, id: "Person/Alice") end + end + + test "raises KeyError when :id is missing" do + adapter = fn req -> {req, ok([])} end + assert_raise KeyError, fn -> Commit.document_history(db_config(adapter), count: 5) end + end + end end diff --git a/test/terminus_db/diff_test.exs b/test/terminus_db/diff_test.exs index b459794..d4947a3 100644 --- a/test/terminus_db/diff_test.exs +++ b/test/terminus_db/diff_test.exs @@ -1,13 +1,25 @@ defmodule TerminusDB.DiffTest do use ExUnit.Case, async: true - alias TerminusDB.{Config, Diff, Error} + alias TerminusDB.{Config, Diff, Error, Patch} import TerminusDB.Test.Helpers defp db_config(adapter) do Config.with_database(Config.new(endpoint: "http://localhost:6363", adapter: adapter), "mydb") end + defp diff_resource_config(adapter) do + Config.with_database( + Config.new( + endpoint: "http://localhost:6363", + user: "admin", + key: "root", + adapter: adapter + ), + "mydb" + ) + end + describe "compare/2" do test "POSTs to diff/:org/:db with before and after" do test = self() @@ -110,4 +122,236 @@ defmodule TerminusDB.DiffTest do end end end + + describe "diff_object/2" do + test "returns a Patch struct on success" do + test = self() + patch_body = %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}} + adapter = capture(test, Req.Response.new(status: 200, body: patch_body)) + + assert {:ok, %Patch{} = patch} = + Diff.diff_object(diff_resource_config(adapter), + before: %{"@id" => "Person/1", "name" => "old"}, + after: %{"@id" => "Person/1", "name" => "new"} + ) + + assert patch.content["name"]["@after"] == "new" + end + + test "POSTs to diff resource path" do + test = self() + adapter = capture(test, ok(%{})) + + Diff.diff_object(diff_resource_config(adapter), + before: %{"name" => "old"}, + after: %{"name" => "new"} + ) + + req = last_request() + assert req.method == :post + assert req.url.path == "/api/diff/admin/mydb/local/branch/main" + end + + test "includes keep when provided" do + test = self() + adapter = capture(test, ok(%{})) + + Diff.diff_object(diff_resource_config(adapter), + before: %{"name" => "old"}, + after: %{"name" => "new"}, + keep: %{"@id" => true} + ) + + req = last_request() + assert {:ok, body} = Jason.decode(req.body) + assert body["keep"] == %{"@id" => true} + end + + test "diff_object! returns Patch or raises" do + adapter = fn req -> + {req, Req.Response.new(status: 200, body: %{"name" => %{"@op" => "SwapValue"}})} + end + + patch = Diff.diff_object!(diff_resource_config(adapter), before: %{}, after: %{}) + assert %Patch{} = patch + end + + test "diff_object! raises on error" do + adapter = fn req -> {req, resp(500, %{"api:message" => "error"})} end + + assert_raise Error, fn -> + Diff.diff_object!(diff_resource_config(adapter), before: %{}, after: %{}) + end + end + + test "returns error on transport failure" do + adapter = fn req -> + {req, Req.Response.new(status: 404, body: %{"@type" => "api:NotFound"})} + end + + assert {:error, _} = + Diff.diff_object(diff_resource_config(adapter), before: %{}, after: %{}) + end + end + + describe "diff_version/2" do + test "returns a Patch struct" do + adapter = fn req -> {req, Req.Response.new(status: 200, body: %{})} end + + assert {:ok, %Patch{} = patch} = + Diff.diff_version(diff_resource_config(adapter), + before_version: "admin/mydb/local/branch/main", + after_version: "admin/mydb/local/branch/feature" + ) + + assert patch.content == %{} + end + + test "POSTs with before_data_version and after_data_version" do + test = self() + adapter = capture(test, ok(%{})) + + Diff.diff_version(diff_resource_config(adapter), + before_version: "admin/mydb/local/commit/abc", + after_version: "admin/mydb/local/commit/def" + ) + + req = last_request() + assert {:ok, body} = Jason.decode(req.body) + assert body["before_data_version"] == "admin/mydb/local/commit/abc" + assert body["after_data_version"] == "admin/mydb/local/commit/def" + end + + test "diff_version! returns Patch or raises" do + adapter = fn req -> {req, Req.Response.new(status: 200, body: %{})} end + + assert %Patch{} = + Diff.diff_version!(diff_resource_config(adapter), + before_version: "a", + after_version: "b" + ) + end + end + + describe "patch/2" do + test "POSTs to /api/patch with before and patch" do + test = self() + adapter = capture(test, Req.Response.new(status: 200, body: %{"name" => "new"})) + + assert {:ok, result} = + Diff.patch(diff_resource_config(adapter), + before: %{"name" => "old"}, + patch: %{"name" => %{"@op" => "SwapValue", "@after" => "new"}} + ) + + assert result["name"] == "new" + + req = last_request() + assert req.method == :post + assert req.url.path == "/api/patch" + end + + test "patch! returns result or raises" do + adapter = fn req -> {req, Req.Response.new(status: 200, body: %{"name" => "new"})} end + + assert Diff.patch!(diff_resource_config(adapter), before: %{}, patch: %{}) == %{ + "name" => "new" + } + end + + test "patch! raises on error" do + adapter = fn req -> {req, resp(400, %{"api:message" => "bad patch"})} end + + assert_raise Error, fn -> + Diff.patch!(diff_resource_config(adapter), before: %{}, patch: %{}) + end + end + end + + describe "patch_resource/2" do + test "POSTs to patch resource path" do + test = self() + adapter = capture(test, ok(%{"api:status" => "api:success"})) + + assert {:ok, resp} = + Diff.patch_resource(diff_resource_config(adapter), + patch: %{"name" => %{"@op" => "SwapValue"}}, + author: "admin", + message: "update" + ) + + assert resp["api:status"] == "api:success" + + req = last_request() + assert req.method == :post + assert req.url.path == "/api/patch/admin/mydb/local/branch/main" + + assert {:ok, body} = Jason.decode(req.body) + assert body["author"] == "admin" + assert body["message"] == "update" + end + + test "patch_resource! returns result or raises" do + adapter = fn req -> {req, ok(%{"api:status" => "api:success"})} end + + assert Diff.patch_resource!(diff_resource_config(adapter), patch: %{})["api:status"] == + "api:success" + end + + test "includes match_final_state when provided" do + test = self() + adapter = capture(test, ok(%{})) + + Diff.patch_resource(diff_resource_config(adapter), + patch: %{}, + match_final_state: true + ) + + req = last_request() + assert {:ok, body} = Jason.decode(req.body) + assert body["match_final_state"] == true + end + end + + describe "apply/2" do + test "POSTs to apply resource path" do + test = self() + adapter = capture(test, ok(%{"api:status" => "api:success"})) + + assert {:ok, resp} = + Diff.apply(diff_resource_config(adapter), + before_version: "admin/mydb/local/commit/abc", + after_version: "admin/mydb/local/commit/def", + author: "admin", + message: "apply" + ) + + assert resp["api:status"] == "api:success" + + req = last_request() + assert req.method == :post + assert req.url.path == "/api/apply/admin/mydb/local/branch/main" + + assert {:ok, body} = Jason.decode(req.body) + assert body["before_commit"] == "admin/mydb/local/commit/abc" + assert body["after_commit"] == "admin/mydb/local/commit/def" + assert body["commit_info"]["author"] == "admin" + end + + test "apply! returns result or raises" do + adapter = fn req -> {req, ok(%{"api:status" => "api:success"})} end + + assert Diff.apply!(diff_resource_config(adapter), before_version: "a", after_version: "b")[ + "api:status" + ] == "api:success" + end + + test "apply! raises on error" do + adapter = fn req -> {req, resp(500, %{"api:message" => "fail"})} end + + assert_raise Error, fn -> + Diff.apply!(diff_resource_config(adapter), before_version: "a", after_version: "b") + end + end + end end diff --git a/test/terminus_db/graphql_test.exs b/test/terminus_db/graphql_test.exs new file mode 100644 index 0000000..6b32c5e --- /dev/null +++ b/test/terminus_db/graphql_test.exs @@ -0,0 +1,135 @@ +defmodule TerminusDB.GraphQLTest do + use ExUnit.Case, async: true + + alias TerminusDB.{Config, GraphQL} + + defp config(resp, status \\ 200) do + Config.with_database( + Config.new( + endpoint: "http://localhost:6363", + user: "admin", + key: "root", + adapter: fn req -> {req, Req.Response.new(status: status, body: resp)} end + ), + "mydb" + ) + end + + describe "query/3" do + test "returns data and errors" do + cfg = config(%{"data" => %{"Person" => [%{"name" => "Alice"}]}, "errors" => nil}) + + assert {:ok, result} = GraphQL.query(cfg, "{ Person { name } }") + assert result.data["Person"] == [%{"name" => "Alice"}] + assert result.errors == nil + end + + test "returns errors when query fails" do + cfg = config(%{"data" => nil, "errors" => [%{"message" => "Cannot query field Foo"}]}) + + assert {:ok, result} = GraphQL.query(cfg, "{ Foo { name } }") + assert result.data == nil + assert length(result.errors) == 1 + end + + test "accepts variables map" do + cfg = config(%{"data" => %{"Person" => [%{"name" => "Bob"}]}}) + + assert {:ok, result} = + GraphQL.query( + cfg, + "query($name: String) { Person(filter: {name: {eq: $name}}) { name } }", + %{"name" => "Bob"} + ) + + assert result.data["Person"] == [%{"name" => "Bob"}] + end + + test "accepts variables via opts" do + cfg = config(%{"data" => %{"Person" => [%{"name" => "Bob"}]}}) + + assert {:ok, result} = + GraphQL.query(cfg, "query($name: String) { Person { name } }", + variables: %{"name" => "Bob"} + ) + + assert result.data["Person"] == [%{"name" => "Bob"}] + end + end + + describe "mutate/3" do + test "returns mutation result" do + cfg = config(%{"data" => %{"_insertDocuments" => ["Person/Alice"]}}) + + mutation = + "mutation { _insertDocuments(json: \"{\\\"@type\\\":\\\"Person\\\",\\\"name\\\":\\\"Alice\\\"}\") }" + + assert {:ok, result} = GraphQL.mutate(cfg, mutation) + assert result.data["_insertDocuments"] == ["Person/Alice"] + end + end + + describe "introspect/2" do + test "returns schema map" do + cfg = config(%{"data" => %{"__schema" => %{"types" => [%{"name" => "Person"}]}}}) + + assert {:ok, schema} = GraphQL.introspect(cfg) + assert schema["__schema"]["types"] == [%{"name" => "Person"}] + end + + test "returns error on transport failure" do + cfg = config(%{"errors" => [%{"message" => "Unauthorized"}]}, 401) + + assert {:error, _} = GraphQL.introspect(cfg) + end + end + + describe "error handling" do + test "returns error on HTTP failure" do + cfg = config(%{"api:message" => "bad request"}, 400) + + assert {:error, _} = GraphQL.query(cfg, "{ Foo { name } }") + end + + test "returns error on 500" do + cfg = config(%{"api:message" => "internal error"}, 500) + + assert {:error, _} = GraphQL.mutate(cfg, "mutation { _insertDocuments(json: \"{}\") }") + end + + test "returns nil errors when not present" do + cfg = config(%{"data" => %{"Person" => []}}) + + assert {:ok, result} = GraphQL.query(cfg, "{ Person { name } }") + assert result.errors == nil + assert result.data == %{"Person" => []} + end + + test "returns {:error, _} when no database is scoped" do + cfg = + Config.new( + endpoint: "http://localhost:6363", + adapter: fn req -> {req, Req.Response.new(status: 200, body: %{})} end + ) + + assert {:error, _} = GraphQL.query(cfg, "{ Person { name } }") + end + + test "honors :organization override" do + cfg = + Config.with_database( + Config.new( + endpoint: "http://localhost:6363", + user: "admin", + key: "root", + adapter: fn req -> + {req, Req.Response.new(status: 200, body: %{"data" => %{}})} + end + ), + "mydb" + ) + + assert {:ok, _} = GraphQL.query(cfg, "{ Person { name } }", organization: "acme") + end + end +end diff --git a/test/terminus_db/patch_test.exs b/test/terminus_db/patch_test.exs new file mode 100644 index 0000000..b01d19d --- /dev/null +++ b/test/terminus_db/patch_test.exs @@ -0,0 +1,153 @@ +defmodule TerminusDB.PatchTest do + use ExUnit.Case, async: true + + alias TerminusDB.Patch + + describe "from_json/1" do + test "parses JSON string into Patch struct" do + assert {:ok, patch} = + Patch.from_json( + ~s({"name": {"@op": "SwapValue", "@before": "old", "@after": "new"}}) + ) + + assert %Patch{} = patch + assert patch.content["name"]["@after"] == "new" + end + + test "returns error on invalid JSON" do + assert {:error, _} = Patch.from_json("not json") + end + end + + describe "from_json!/1" do + test "parses or raises" do + patch = + Patch.from_json!(~s({"name": {"@op": "SwapValue", "@before": "old", "@after": "new"}})) + + assert patch.content["name"]["@before"] == "old" + end + end + + describe "to_json/1" do + test "serializes to JSON string" do + patch = %Patch{ + content: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}} + } + + json = Patch.to_json(patch) + assert is_binary(json) + assert json =~ "SwapValue" + end + end + + describe "update/1" do + test "extracts after values from SwapValue" do + patch = %Patch{ + content: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}} + } + + assert Patch.update(patch) == %{"name" => "new"} + end + + test "handles nested SwapValue" do + patch = %Patch{ + content: %{ + "name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}, + "address" => %{"city" => %{"@op" => "SwapValue", "@before" => "NYC", "@after" => "LA"}} + } + } + + assert Patch.update(patch) == %{"name" => "new", "address" => %{"city" => "LA"}} + end + end + + describe "before/1" do + test "extracts before values from SwapValue" do + patch = %Patch{ + content: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}} + } + + assert Patch.before(patch) == %{"name" => "old"} + end + + test "handles nested SwapValue" do + patch = %Patch{ + content: %{ + "name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}, + "address" => %{"city" => %{"@op" => "SwapValue", "@before" => "NYC", "@after" => "LA"}} + } + } + + assert Patch.before(patch) == %{"name" => "old", "address" => %{"city" => "NYC"}} + end + + test "preserves non-SwapValue values" do + patch = %Patch{ + content: %{ + "id" => "Person/1", + "name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"} + } + } + + assert Patch.before(patch) == %{"id" => "Person/1", "name" => "old"} + end + + test "handles deeply nested non-SwapValue" do + patch = %Patch{ + content: %{ + "meta" => %{ + "created" => "2024-01-01", + "tag" => %{"@op" => "SwapValue", "@before" => "a", "@after" => "b"} + } + } + } + + result = Patch.before(patch) + assert result["meta"]["created"] == "2024-01-01" + assert result["meta"]["tag"] == "a" + end + end + + describe "copy/1" do + test "creates a deep copy" do + patch = %Patch{content: %{"name" => "value"}} + copy = Patch.copy(patch) + assert copy == patch + assert copy.content == patch.content + end + + test "deep copies nested maps" do + patch = %Patch{content: %{"a" => %{"b" => %{"c" => [1, 2, 3]}}}} + copy = Patch.copy(patch) + assert copy.content["a"]["b"]["c"] == [1, 2, 3] + end + + test "deep copies lists" do + patch = %Patch{content: [%{"x" => 1}, %{"y" => 2}]} + copy = Patch.copy(patch) + assert copy.content == [%{"x" => 1}, %{"y" => 2}] + end + end + + describe "update/1 edge cases" do + test "returns empty map when no SwapValue ops" do + patch = %Patch{content: %{"name" => "unchanged"}} + assert Patch.update(patch) == %{} + end + + test "handles deep nesting" do + patch = %Patch{ + content: %{ + "a" => %{"b" => %{"c" => %{"@op" => "SwapValue", "@before" => 1, "@after" => 2}}} + } + } + + assert Patch.update(patch) == %{"a" => %{"b" => %{"c" => 2}}} + end + + test "handles list content" do + patch = %Patch{content: [%{"@op" => "SwapValue", "@after" => "x"}]} + assert Patch.update(patch) == %{} + end + end +end diff --git a/test/terminus_db/prefix_test.exs b/test/terminus_db/prefix_test.exs new file mode 100644 index 0000000..63db48e --- /dev/null +++ b/test/terminus_db/prefix_test.exs @@ -0,0 +1,196 @@ +defmodule TerminusDB.PrefixTest do + use ExUnit.Case, async: true + + alias TerminusDB.{Config, Error, Prefix} + import TerminusDB.Test.Helpers + + defp db_config(adapter) do + Config.with_database( + Config.new(endpoint: "http://localhost:6363", user: "admin", key: "root", adapter: adapter), + "mydb" + ) + end + + describe "get/2" do + test "GETs prefix/:org/:db/:repo/branch/:branch/:name and returns URI" do + test = self() + + adapter = + capture( + test, + Req.Response.new(status: 200, body: %{"api:prefix_uri" => "http://example.org/"}) + ) + + assert {:ok, "http://example.org/"} = Prefix.get(db_config(adapter), "ex") + req = last_request() + assert req.method == :get + assert req.url.path == "/api/prefix/admin/mydb/local/branch/main/ex" + end + + test "get! returns the prefix URI or raises" do + adapter = fn req -> + {req, Req.Response.new(status: 200, body: %{"api:prefix_uri" => "http://example.org/"})} + end + + assert Prefix.get!(db_config(adapter), "ex") == "http://example.org/" + end + + test "get! raises on error" do + adapter = fn req -> {req, resp(404, %{"@type" => "api:NotFound"})} end + assert_raise Error, fn -> Prefix.get!(db_config(adapter), "missing") end + end + end + + describe "add/3" do + test "POSTs prefix with URI body" do + test = self() + adapter = capture(test, ok(%{"api:status" => "api:success"})) + + assert {:ok, resp} = Prefix.add(db_config(adapter), "ex", "http://example.org/") + assert resp["api:status"] == "api:success" + req = last_request() + assert req.method == :post + assert {:ok, body} = Jason.decode(req.body) + assert body["uri"] == "http://example.org/" + end + + test "add! returns response or raises" do + adapter = fn req -> {req, ok(%{"api:status" => "api:success"})} end + + assert Prefix.add!(db_config(adapter), "ex", "http://example.org/")["api:status"] == + "api:success" + end + + test "add! raises on error" do + adapter = fn req -> {req, resp(400, %{"api:message" => "exists"})} end + assert_raise Error, fn -> Prefix.add!(db_config(adapter), "ex", "http://example.org/") end + end + end + + describe "update/3" do + test "PUTs prefix with URI body" do + test = self() + adapter = capture(test, ok(%{"api:status" => "api:success"})) + + assert {:ok, resp} = Prefix.update(db_config(adapter), "ex", "http://example.com/") + assert resp["api:status"] == "api:success" + req = last_request() + assert req.method == :put + end + + test "update! returns response or raises" do + adapter = fn req -> {req, ok(%{"api:status" => "api:success"})} end + + assert Prefix.update!(db_config(adapter), "ex", "http://example.com/")["api:status"] == + "api:success" + end + + test "update! raises on error" do + adapter = fn req -> {req, resp(404, %{"@type" => "api:NotFound"})} end + assert_raise Error, fn -> Prefix.update!(db_config(adapter), "missing", "http://x/") end + end + end + + describe "upsert/3" do + test "PUTs with create=true param" do + test = self() + adapter = capture(test, ok(%{"api:status" => "api:success"})) + + assert {:ok, resp} = Prefix.upsert(db_config(adapter), "ex", "http://example.org/") + assert resp["api:status"] == "api:success" + req = last_request() + assert req.method == :put + assert req.url.query =~ "create=true" + end + + test "upsert! returns response or raises" do + adapter = fn req -> {req, ok(%{"api:status" => "api:success"})} end + + assert Prefix.upsert!(db_config(adapter), "ex", "http://example.org/")["api:status"] == + "api:success" + end + end + + describe "delete/2" do + test "DELETEs prefix" do + test = self() + adapter = capture(test, ok(%{"api:status" => "api:success"})) + + assert {:ok, resp} = Prefix.delete(db_config(adapter), "ex") + assert resp["api:status"] == "api:success" + req = last_request() + assert req.method == :delete + end + + test "delete! returns response or raises" do + adapter = fn req -> {req, ok(%{"api:status" => "api:success"})} end + assert Prefix.delete!(db_config(adapter), "ex")["api:status"] == "api:success" + end + + test "delete! raises on error" do + adapter = fn req -> {req, resp(404, %{"@type" => "api:NotFound"})} end + assert_raise Error, fn -> Prefix.delete!(db_config(adapter), "missing") end + end + end + + describe "all/2" do + test "GETs prefixes/:org/:db and returns all prefixes" do + test = self() + + adapter = + capture( + test, + Req.Response.new( + status: 200, + body: %{"@base" => "terminusdb:///data/", "@schema" => "terminusdb:///schema#"} + ) + ) + + assert {:ok, prefixes} = Prefix.all(db_config(adapter)) + assert prefixes["@schema"] == "terminusdb:///schema#" + req = last_request() + assert req.method == :get + assert req.url.path == "/api/prefixes/admin/mydb" + end + + test "all! returns all prefixes or raises" do + adapter = fn req -> + {req, + Req.Response.new( + status: 200, + body: %{"@base" => "terminusdb:///data/", "@schema" => "terminusdb:///schema#"} + )} + end + + assert Prefix.all!(db_config(adapter))["@base"] == "terminusdb:///data/" + end + + test "all! raises on error" do + adapter = fn req -> {req, resp(500, %{"api:message" => "fail"})} end + assert_raise Error, fn -> Prefix.all!(db_config(adapter)) end + end + end + + describe "error handling" do + test "returns {:error, _} when no database is scoped" do + config = Config.new(endpoint: "http://localhost:6363", adapter: fn r -> {r, ok()} end) + assert {:error, _} = Prefix.get(config, "ex") + end + + test "all returns {:error, _} when no database is scoped" do + config = Config.new(endpoint: "http://localhost:6363", adapter: fn r -> {r, ok()} end) + assert {:error, _} = Prefix.all(config) + end + + test "honors :organization override" do + test = self() + + adapter = + capture(test, Req.Response.new(status: 200, body: %{"api:prefix_uri" => "http://x/"})) + + Prefix.get(db_config(adapter), "ex", organization: "acme") + req = last_request() + assert req.url.path == "/api/prefix/acme/mydb/local/branch/main/ex" + end + end +end diff --git a/test/terminus_db/remote_test.exs b/test/terminus_db/remote_test.exs new file mode 100644 index 0000000..a89f1a1 --- /dev/null +++ b/test/terminus_db/remote_test.exs @@ -0,0 +1,91 @@ +defmodule TerminusDB.RemoteTest do + use ExUnit.Case, async: true + + alias TerminusDB.{Config, Remote} + + defp config(resp \\ %{"api:status" => "api:success"}, status \\ 200) do + Config.with_database( + Config.new( + endpoint: "http://localhost:6363", + user: "admin", + key: "root", + adapter: fn req -> {req, Req.Response.new(status: status, body: resp)} end + ), + "mydb" + ) + end + + describe "clone/3" do + test "clones a remote repository" do + cfg = + Config.new( + endpoint: "http://localhost:6363", + user: "admin", + key: "root", + adapter: fn req -> + {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} + end + ) + + assert {:ok, resp} = + Remote.clone(cfg, "https://data.terminusdb.org/public/star-wars", "star-wars", + label: "Star Wars" + ) + + assert resp["api:status"] == "api:success" + end + + test "clone! returns response or raises" do + cfg = + Config.new( + endpoint: "http://localhost:6363", + user: "admin", + key: "root", + adapter: fn req -> + {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} + end + ) + + assert Remote.clone!(cfg, "https://example.com/db", "newdb")["api:status"] == "api:success" + end + end + + describe "fetch/2" do + test "fetches from remote" do + cfg = config(%{"api:status" => "api:success", "api:head_has_changed" => true}) + assert {:ok, resp} = Remote.fetch(cfg) + assert resp["api:head_has_changed"] == true + end + + test "fetch! returns response or raises" do + cfg = config(%{"api:head_has_changed" => false}) + assert Remote.fetch!(cfg)["api:head_has_changed"] == false + end + end + + describe "push/3" do + test "pushes to remote" do + cfg = config(%{"api:repo_head_updated" => true}) + assert {:ok, resp} = Remote.push(cfg, "origin", "main", author: "admin", message: "push") + assert resp["api:repo_head_updated"] == true + end + + test "push! returns response or raises" do + cfg = config(%{"api:repo_head_updated" => true}) + assert Remote.push!(cfg, "origin", "main")["api:repo_head_updated"] == true + end + end + + describe "pull/3" do + test "pulls from remote" do + cfg = config() + assert {:ok, resp} = Remote.pull(cfg, "origin", "main", author: "admin", message: "pull") + assert resp["api:status"] == "api:success" + end + + test "pull! returns response or raises" do + cfg = config() + assert Remote.pull!(cfg, "origin", "main")["api:status"] == "api:success" + end + end +end diff --git a/test/terminus_db/triples_test.exs b/test/terminus_db/triples_test.exs new file mode 100644 index 0000000..5db7055 --- /dev/null +++ b/test/terminus_db/triples_test.exs @@ -0,0 +1,62 @@ +defmodule TerminusDB.TriplesTest do + use ExUnit.Case, async: true + + alias TerminusDB.{Config, Triples} + + defp config(resp \\ %{"api:status" => "api:success"}, status \\ 200) do + Config.with_database( + Config.new( + endpoint: "http://localhost:6363", + user: "admin", + key: "root", + adapter: fn req -> {req, Req.Response.new(status: status, body: resp)} end + ), + "mydb" + ) + end + + describe "get/2" do + test "returns turtle string" do + cfg = config("@prefix : .") + assert {:ok, turtle} = Triples.get(cfg) + assert turtle == "@prefix : ." + end + + test "get! returns turtle or raises" do + cfg = config("@prefix : .") + assert Triples.get!(cfg) == "@prefix : ." + end + end + + describe "update/3" do + test "replaces graph with turtle" do + cfg = config() + + assert {:ok, resp} = + Triples.update(cfg, ":foo :bar :baz .", author: "admin", message: "replace") + + assert resp["api:status"] == "api:success" + end + + test "update! returns response or raises" do + cfg = config() + assert Triples.update!(cfg, ":foo :bar :baz .")["api:status"] == "api:success" + end + end + + describe "insert/3" do + test "inserts turtle additively" do + cfg = config() + + assert {:ok, resp} = + Triples.insert(cfg, ":foo :bar :baz .", author: "admin", message: "add") + + assert resp["api:status"] == "api:success" + end + + test "insert! returns response or raises" do + cfg = config() + assert Triples.insert!(cfg, ":foo :bar :baz .")["api:status"] == "api:success" + end + end +end diff --git a/test/terminus_db/woql/rdf_list_test.exs b/test/terminus_db/woql/rdf_list_test.exs new file mode 100644 index 0000000..eb80a1e --- /dev/null +++ b/test/terminus_db/woql/rdf_list_test.exs @@ -0,0 +1,222 @@ +defmodule TerminusDB.WOQL.RDFListTest do + use ExUnit.Case, async: true + + alias TerminusDB.WOQL + alias TerminusDB.WOQL.RDFList + + describe "rdflist_peek/2" do + test "builds a triple for rdf:first" do + q = RDFList.rdflist_peek("v:List", "v:First") + assert q.op == :triple + assert q.args == ["v:List", "rdf:first", "v:First"] + end + + test "encodes as Triple" do + jsonld = WOQL.to_jsonld(RDFList.rdflist_peek("v:List", "v:First")) + assert jsonld["@type"] == "Triple" + assert jsonld["predicate"] == %{"@type" => "NodeValue", "node" => "rdf:first"} + end + end + + describe "rdflist_empty/1" do + test "builds an eq with rdf:nil" do + q = RDFList.rdflist_empty("v:List") + assert q.op == :eq + assert q.args == ["v:List", %{"@type" => "NodeValue", "node" => "rdf:nil"}] + end + + test "encodes as Equals" do + jsonld = WOQL.to_jsonld(RDFList.rdflist_empty("v:List")) + assert jsonld["@type"] == "Equals" + end + end + + describe "rdflist_is_empty/1" do + test "builds an eq with rdf:nil" do + q = RDFList.rdflist_is_empty("v:List") + assert q.op == :eq + end + end + + describe "rdflist_last/2" do + test "builds an And query" do + q = RDFList.rdflist_last("v:List", "v:Last") + assert q.op == :and + assert is_list(q.args) + assert length(q.args) == 3 + end + + test "encodes as And" do + jsonld = WOQL.to_jsonld(RDFList.rdflist_last("v:List", "v:Last")) + assert jsonld["@type"] == "And" + end + end + + describe "rdflist_list/2" do + test "builds an And query" do + q = RDFList.rdflist_list("v:List", "v:Array") + assert q.op == :and + assert is_list(q.args) + end + end + + describe "rdflist_member/2" do + test "builds an And query" do + q = RDFList.rdflist_member("v:List", "v:Elem") + assert q.op == :and + end + end + + describe "rdflist_nth0/3" do + test "with index 0 returns rdf:first triple" do + q = RDFList.rdflist_nth0("v:List", 0, "v:Elem") + assert q.op == :triple + end + + test "with index > 0 builds an And with nested nth" do + q = RDFList.rdflist_nth0("v:List", 2, "v:Elem") + assert q.op == :and + end + end + + describe "rdflist_nth1/3" do + test "with index 1 returns rdf:first triple" do + q = RDFList.rdflist_nth1("v:List", 1, "v:Elem") + assert q.op == :triple + end + + test "with index > 1 builds an And" do + q = RDFList.rdflist_nth1("v:List", 3, "v:Elem") + assert q.op == :and + end + end + + describe "rdflist_pop/2" do + test "builds an And query with delete operations" do + q = RDFList.rdflist_pop("v:List", "v:Value") + assert q.op == :and + ops = Enum.map(q.args, & &1.op) + assert :delete_triple in ops + end + end + + describe "rdflist_push/3" do + test "builds an And query with add operations" do + q = RDFList.rdflist_push("v:List", "v:Value", "v:NewHead") + assert q.op == :and + ops = Enum.map(q.args, & &1.op) + assert :add_triple in ops + end + + test "includes new_head_var in output" do + q = RDFList.rdflist_push("v:List", "v:Value", "v:NewHead") + json = Jason.encode!(WOQL.to_jsonld(q)) + assert json =~ "NewHead" + end + end + + describe "rdflist_append/2" do + test "builds an And query with add and delete operations" do + q = RDFList.rdflist_append("v:List", "v:Value") + assert q.op == :and + ops = Enum.map(q.args, & &1.op) + assert :add_triple in ops + assert :delete_triple in ops + end + end + + describe "rdflist_clear/2" do + test "builds an And query" do + q = RDFList.rdflist_clear("v:List", "v:NewList") + assert q.op == :and + end + + test "guards deletes inside opt blocks" do + q = RDFList.rdflist_clear("v:List", "v:NewList") + json = Jason.encode!(WOQL.to_jsonld(q)) + assert json =~ "Optional" + assert json =~ "DeleteTriple" + end + end + + describe "rdflist_length/2" do + test "builds an And query" do + q = RDFList.rdflist_length("v:List", "v:Len") + assert q.op == :and + end + end + + describe "rdflist_insert/3" do + test "builds an And query with add_triple" do + q = RDFList.rdflist_insert("v:List", 1, "v:Value") + assert q.op == :and + ops = Enum.map(q.args, & &1.op) + assert :add_triple in ops + end + end + + describe "rdflist_drop/2" do + test "builds an And query" do + q = RDFList.rdflist_drop("v:List", 1) + assert q.op == :and + end + + test "includes delete_triple operations" do + q = RDFList.rdflist_drop("v:List", 1) + json = Jason.encode!(WOQL.to_jsonld(q)) + assert json =~ "DeleteTriple" + end + + test "drop at 0 deletes from head" do + q = RDFList.rdflist_drop("v:List", 0) + json = Jason.encode!(WOQL.to_jsonld(q)) + assert json =~ "DeleteTriple" + assert json =~ "rdf:first" + end + end + + describe "rdflist_swap/3" do + test "builds an And query" do + q = RDFList.rdflist_swap("v:List", 0, 2) + assert q.op == :and + end + + test "includes both delete_triple and add_triple write operations" do + q = RDFList.rdflist_swap("v:List", 0, 2) + json = Jason.encode!(WOQL.to_jsonld(q)) + assert json =~ "DeleteTriple" + assert json =~ "AddTriple" + end + end + + describe "rdflist_nth0 with variable index" do + test "does not crash and includes dec variable" do + q = RDFList.rdflist_nth0("v:List", "v:Index", "v:Elem") + assert q.op == :and + json = Jason.encode!(WOQL.to_jsonld(q)) + assert json =~ "RDFList_dec" + end + end + + describe "rdflist_slice/4" do + test "includes end_val in the generated query" do + q = RDFList.rdflist_slice("v:List", 0, 3, "v:Result") + assert q.op == :and + json = Jason.encode!(WOQL.to_jsonld(q)) + # The slice should involve counting/navigation, not just collecting all + refute json =~ "\"rdf:rest*\"" + end + end + + describe "unique variable generation" do + test "localize generates unique variable names" do + q1 = RDFList.rdflist_last("v:List", "v:Last") + q2 = RDFList.rdflist_last("v:List", "v:Last") + + jsonld1 = Enum.map(WOQL.to_jsonld(q1)["and"], &inspect/1) + jsonld2 = Enum.map(WOQL.to_jsonld(q2)["and"], &inspect/1) + + assert jsonld1 != jsonld2 + end + end +end diff --git a/test/terminus_db/woql_test.exs b/test/terminus_db/woql_test.exs index fd2abd1..3de8cca 100644 --- a/test/terminus_db/woql_test.exs +++ b/test/terminus_db/woql_test.exs @@ -1641,6 +1641,349 @@ defmodule TerminusDB.WOQLTest do end end + describe "range queries" do + test "triple_slice/5 encodes as TripleSlice" do + jsonld = WOQL.to_jsonld(WOQL.triple_slice("v:S", "v:P", "v:O", 10, 100)) + assert jsonld["@type"] == "TripleSlice" + assert jsonld["subject"] == %{"@type" => "NodeValue", "variable" => "S"} + + assert jsonld["low"] == %{ + "@type" => "Value", + "data" => %{"@type" => "xsd:integer", "@value" => 10} + } + + assert jsonld["high"] == %{ + "@type" => "Value", + "data" => %{"@type" => "xsd:integer", "@value" => 100} + } + + refute Map.has_key?(jsonld, "graph") + end + + test "quad_slice/6 encodes as TripleSlice with graph" do + jsonld = WOQL.to_jsonld(WOQL.quad_slice("v:S", "v:P", "v:O", 10, 100, "instance")) + assert jsonld["@type"] == "TripleSlice" + assert jsonld["graph"] == "instance" + end + + test "triple_slice_rev/5 encodes as TripleSliceRev" do + jsonld = WOQL.to_jsonld(WOQL.triple_slice_rev("v:S", "v:P", "v:O", 10, 100)) + assert jsonld["@type"] == "TripleSliceRev" + refute Map.has_key?(jsonld, "graph") + end + + test "quad_slice_rev/6 encodes as TripleSliceRev with graph" do + jsonld = WOQL.to_jsonld(WOQL.quad_slice_rev("v:S", "v:P", "v:O", 10, 100, "instance")) + assert jsonld["@type"] == "TripleSliceRev" + assert jsonld["graph"] == "instance" + end + + test "triple_next/4 encodes as TripleNext" do + jsonld = WOQL.to_jsonld(WOQL.triple_next("v:S", "v:P", "v:O", "v:Next")) + assert jsonld["@type"] == "TripleNext" + assert jsonld["next"] == %{"@type" => "Value", "variable" => "Next"} + refute Map.has_key?(jsonld, "graph") + end + + test "quad_next/5 encodes as TripleNext with graph" do + jsonld = WOQL.to_jsonld(WOQL.quad_next("v:S", "v:P", "v:O", "v:Next", "instance")) + assert jsonld["@type"] == "TripleNext" + assert jsonld["graph"] == "instance" + end + + test "triple_previous/4 encodes as TriplePrevious" do + jsonld = WOQL.to_jsonld(WOQL.triple_previous("v:S", "v:P", "v:O", "v:Prev")) + assert jsonld["@type"] == "TriplePrevious" + assert jsonld["previous"] == %{"@type" => "Value", "variable" => "Prev"} + refute Map.has_key?(jsonld, "graph") + end + + test "quad_previous/5 encodes as TriplePrevious with graph" do + jsonld = WOQL.to_jsonld(WOQL.quad_previous("v:S", "v:P", "v:O", "v:Prev", "instance")) + assert jsonld["@type"] == "TriplePrevious" + assert jsonld["graph"] == "instance" + end + + test "round-trips triple_slice" do + q = WOQL.triple_slice("v:S", "v:P", "v:O", 10, 100) + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips quad_slice" do + q = WOQL.quad_slice("v:S", "v:P", "v:O", 10, 100, "instance") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips triple_slice_rev" do + q = WOQL.triple_slice_rev("v:S", "v:P", "v:O", 10, 100) + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips quad_slice_rev" do + q = WOQL.quad_slice_rev("v:S", "v:P", "v:O", 10, 100, "instance") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips triple_next" do + q = WOQL.triple_next("v:S", "v:P", "v:O", "v:Next") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips quad_next" do + q = WOQL.quad_next("v:S", "v:P", "v:O", "v:Next", "instance") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips triple_previous" do + q = WOQL.triple_previous("v:S", "v:P", "v:O", "v:Prev") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips quad_previous" do + q = WOQL.quad_previous("v:S", "v:P", "v:O", "v:Prev", "instance") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + end + + describe "temporal / Allen interval algebra" do + test "interval/3 encodes as Interval" do + jsonld = WOQL.to_jsonld(WOQL.interval("v:S", "v:E", "v:I")) + assert jsonld["@type"] == "Interval" + assert jsonld["start"] == %{"@type" => "Value", "variable" => "S"} + end + + test "interval_relation/5 encodes as IntervalRelation" do + jsonld = WOQL.to_jsonld(WOQL.interval_relation("v:R", "v:XS", "v:XE", "v:YS", "v:YE")) + assert jsonld["@type"] == "IntervalRelation" + assert jsonld["start2"] == %{"@type" => "Value", "variable" => "YS"} + end + + test "date_duration/3 encodes as DateDuration" do + jsonld = WOQL.to_jsonld(WOQL.date_duration("v:S", "v:E", "v:D")) + assert jsonld["@type"] == "DateDuration" + end + + test "day_after/2 encodes as DayAfter" do + jsonld = WOQL.to_jsonld(WOQL.day_after("v:D", "v:N")) + assert jsonld["@type"] == "DayAfter" + end + + test "weekday/2 encodes as Weekday" do + jsonld = WOQL.to_jsonld(WOQL.weekday("v:D", "v:W")) + assert jsonld["@type"] == "Weekday" + end + + test "iso_week/3 encodes as IsoWeek" do + jsonld = WOQL.to_jsonld(WOQL.iso_week("v:D", "v:Y", "v:W")) + assert jsonld["@type"] == "IsoWeek" + end + + test "in_range/3 encodes as InRange" do + jsonld = WOQL.to_jsonld(WOQL.in_range("v:V", 10, 100)) + assert jsonld["@type"] == "InRange" + assert jsonld["start"]["data"]["@value"] == 10 + end + + test "sequence/5 with step and count encodes both" do + jsonld = WOQL.to_jsonld(WOQL.sequence("v:V", 1, 10, 2, 5)) + assert jsonld["@type"] == "Sequence" + assert jsonld["step"]["data"]["@value"] == 2 + assert jsonld["count"]["data"]["@value"] == 5 + end + + test "sequence/3 without step and count omits them" do + jsonld = WOQL.to_jsonld(WOQL.sequence("v:V", 1, 10)) + assert jsonld["@type"] == "Sequence" + refute Map.has_key?(jsonld, "step") + refute Map.has_key?(jsonld, "count") + end + + test "range_min/2 encodes as RangeMin" do + jsonld = WOQL.to_jsonld(WOQL.range_min("v:L", "v:Min")) + assert jsonld["@type"] == "RangeMin" + end + + test "range_max/2 encodes as RangeMax" do + jsonld = WOQL.to_jsonld(WOQL.range_max("v:L", "v:Max")) + assert jsonld["@type"] == "RangeMax" + end + + test "round-trips interval" do + q = WOQL.interval("v:S", "v:E", "v:I") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips interval_relation" do + q = WOQL.interval_relation("v:R", "v:XS", "v:XE", "v:YS", "v:YE") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips date_duration" do + q = WOQL.date_duration("v:S", "v:E", "v:D") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips day_after" do + q = WOQL.day_after("v:D", "v:N") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips day_before" do + q = WOQL.day_before("v:D", "v:P") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips weekday" do + q = WOQL.weekday("v:D", "v:W") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips weekday_sunday_start" do + q = WOQL.weekday_sunday_start("v:D", "v:W") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips iso_week" do + q = WOQL.iso_week("v:D", "v:Y", "v:W") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips month_start_date" do + q = WOQL.month_start_date("v:YM", "v:D") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips month_end_date" do + q = WOQL.month_end_date("v:YM", "v:D") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips month_start_dates" do + q = WOQL.month_start_dates("v:D", "v:S", "v:E") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips month_end_dates" do + q = WOQL.month_end_dates("v:D", "v:S", "v:E") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips in_range" do + q = WOQL.in_range("v:V", 10, 100) + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips sequence without step/count" do + q = WOQL.sequence("v:V", 1, 10) + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips sequence with step and count" do + q = WOQL.sequence("v:V", 1, 10, 2, 5) + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips range_min" do + q = WOQL.range_min("v:L", "v:Min") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips range_max" do + q = WOQL.range_max("v:L", "v:Max") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips interval_start_duration" do + q = WOQL.interval_start_duration("v:S", "v:D", "v:I") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips interval_duration_end" do + q = WOQL.interval_duration_end("v:D", "v:E", "v:I") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips interval_relation_typed" do + q = WOQL.interval_relation_typed("v:R", "v:X", "v:Y") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + end + + describe "CSV / IO" do + test "woql_as/1 builds Column objects with name indicator" do + cols = WOQL.woql_as([{"name", "v:Name"}]) + [col] = cols + assert col["@type"] == "Column" + assert col["indicator"] == %{"@type" => "Indicator", "name" => "name"} + assert col["variable"] == "Name" + end + + test "woql_as/1 builds Column objects with index indicator" do + cols = WOQL.woql_as([{0, "v:Idx"}]) + [col] = cols + assert col["indicator"] == %{"@type" => "Indicator", "index" => 0} + end + + test "get/2 encodes as Get" do + cols = WOQL.woql_as([{"name", "v:Name"}]) + jsonld = WOQL.to_jsonld(WOQL.get(cols, WOQL.file("data.csv"))) + assert jsonld["@type"] == "Get" + assert jsonld["columns"] == cols + assert jsonld["resource"]["@type"] == "QueryResource" + end + + test "put/3 encodes as Put" do + cols = WOQL.woql_as([{"name", "v:Name"}]) + + jsonld = + WOQL.to_jsonld(WOQL.put(cols, WOQL.triple("v:S", "p", "v:O"), WOQL.file("out.csv"))) + + assert jsonld["@type"] == "Put" + assert jsonld["query"]["@type"] == "Triple" + end + + test "file/2 encodes as QueryResource with FileResource" do + jsonld = WOQL.to_jsonld(WOQL.file("data.csv")) + assert jsonld["@type"] == "QueryResource" + assert jsonld["source"] == %{"@type" => "FileResource", "file_name" => "data.csv"} + assert jsonld["format"]["format_type"]["@value"] == "csv" + end + + test "remote/2 encodes as QueryResource with RemoteResource" do + jsonld = WOQL.to_jsonld(WOQL.remote("https://example.com/data.csv")) + + assert jsonld["source"] == %{ + "@type" => "RemoteResource", + "url" => "https://example.com/data.csv" + } + end + + test "post/2 encodes as QueryResource with PostResource" do + jsonld = WOQL.to_jsonld(WOQL.post("upload.csv")) + assert jsonld["source"] == %{"@type" => "PostResource", "file_name" => "upload.csv"} + end + + test "file/2 with custom format" do + jsonld = WOQL.to_jsonld(WOQL.file("data.json", format: "json")) + assert jsonld["format"]["format_type"]["@value"] == "json" + end + + test "round-trips file" do + q = WOQL.file("data.csv") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips remote" do + q = WOQL.remote("https://example.com/data.csv") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips post" do + q = WOQL.post("upload.csv") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + end + describe "round-trip: to_jsonld ∘ from_jsonld" do test "round-trips a triple" do q = WOQL.triple("v:Person", "name", "v:Name") @@ -1681,6 +2024,63 @@ defmodule TerminusDB.WOQLTest do q = WOQL.triple("v:Person", "rdf:type", "@schema:Person") assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q end + + test "round-trips a get (CSV) query" do + cols = WOQL.woql_as([{"name", "v:Name"}]) + q = WOQL.get(cols, WOQL.file("data.csv")) + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips a put (CSV) query" do + cols = WOQL.woql_as([{"name", "v:Name"}]) + q = WOQL.put(cols, WOQL.triple("v:S", "p", "v:O"), WOQL.file("out.csv")) + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips a remote resource" do + q = WOQL.remote("https://example.com/data.csv", format: "json") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips a post resource" do + q = WOQL.post("upload.csv", format: "json") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips interval_start_duration" do + q = WOQL.interval_start_duration("v:S", "v:D", "v:I") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips interval_duration_end" do + q = WOQL.interval_duration_end("v:D", "v:E", "v:I") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips interval_relation_typed" do + q = WOQL.interval_relation_typed("v:R", "v:X", "v:Y") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips day_before" do + q = WOQL.day_before("v:D", "v:P") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips weekday_sunday_start" do + q = WOQL.weekday_sunday_start("v:D", "v:W") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips month_start_date" do + q = WOQL.month_start_date("v:YM", "v:D") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end + + test "round-trips month_end_date" do + q = WOQL.month_end_date("v:YM", "v:D") + assert WOQL.from_jsonld(WOQL.to_jsonld(q)) == q + end end describe "execute/3" do