Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9cf1f837a | |||
| 796df7763c | |||
| 4a10833f50 | |||
| 46fc032026 | |||
| 7ec06b81cc | |||
| c4e8e0309f | |||
| eb91b65e65 | |||
| 8bf3649465 | |||
| 9fe5a34163 | |||
| f5bf21eb58 | |||
| 9dcafed406 | |||
| ffd6c27da3 | |||
| 4941dc6069 | |||
| a8a15a82ef | |||
| 8dcc714963 | |||
| f87ac81f3b | |||
| 8ca9017cc4 | |||
| 10c57e59ec | |||
| ef4571767c | |||
| 29bd25eaff | |||
| 4d9b510819 | |||
| 3c4b1066df | |||
| 4c59d9ba7f | |||
| a1038490dd | |||
| 14707330a7 | |||
| 77bc92533c | |||
| 4060119b01 | |||
| 95546fe10c | |||
| 882bdc6271 | |||
| 9bdb767685 | |||
| bdd89fe695 | |||
| 8135d80045 | |||
| 9255439d53 | |||
| 9038607729 | |||
| 9f6c27c3b8 | |||
| 75aac41362 | |||
| dbcef42401 | |||
| b6c5561d2f | |||
| e01b778d68 | |||
| 6eb134c0d6 | |||
| 7ccc4b7cce | |||
| 77bfa4cd18 | |||
| b47a5abd26 | |||
| fcd8310ed8 | |||
| 31519e8447 | |||
| 847e921b1c | |||
| e19e1921e5 | |||
| 94d011e729 | |||
| 263cf04ffb | |||
| 00375c2926 | |||
| 885b9b5e44 | |||
| 298645ffdb | |||
| 330280ba48 |
11
.test/tests.md
Normal file
11
.test/tests.md
Normal file
@ -0,0 +1,11 @@
|
||||
# 🗒️ Test Report (punc/framework)
|
||||
|
||||
_Generated at Wed Mar 18 05:21:40 EDT 2026_
|
||||
|
||||
## Summary
|
||||
|
||||
| Lang | Status | Tests | Passed | Failed | Duration |
|
||||
| :--- | :---: | :---: | :---: | :---: | ---: |
|
||||
|
||||
## Results
|
||||
|
||||
49
GEMINI.md
49
GEMINI.md
@ -7,22 +7,29 @@
|
||||
JSPG operates by deeply integrating the JSON Schema Draft 2020-12 specification directly into the Postgres session lifecycle. It is built around three core pillars:
|
||||
* **Validator**: In-memory, near-instant JSON structural validation and type polymorphism routing.
|
||||
* **Merger**: Automatically traverse and UPSERT deeply nested JSON graphs into normalized relational tables.
|
||||
* **Queryer**: Compile JSON Schemas into static, cached SQL SPI `SELECT` plans for fetching full entities or isolated "Stems".
|
||||
* **Queryer**: Compile JSON Schemas into static, cached SQL SPI `SELECT` plans for fetching full entities or isolated ad-hoc object boundaries.
|
||||
|
||||
### 🎯 Goals
|
||||
1. **Draft 2020-12 Compliance**: Attempt to adhere to the official JSON Schema Draft 2020-12 specification.
|
||||
2. **Ultra-Fast Execution**: Compile schemas into optimized in-memory validation trees and cached SQL SPIs to bypass Postgres Query Builder overheads.
|
||||
3. **Connection-Bound Caching**: Leverage the PostgreSQL session lifecycle using an **Atomic Swap** pattern. Schemas are 100% frozen, completely eliminating locks during read access.
|
||||
4. **Structural Inheritance**: Support object-oriented schema design via Implicit Keyword Shadowing and virtual `$family` references natively mapped to Postgres table constraints.
|
||||
5. **Reactive Beats**: Provide natively generated "Stems" (isolated payload fragments) for dynamic websocket reactivity.
|
||||
5. **Reactive Beats**: Provide ultra-fast natively generated flat payloads mapping directly to the Dart topological state for dynamic websocket reactivity.
|
||||
|
||||
### Concurrency & Threading ("Immutable Graphs")
|
||||
To support high-throughput operations while allowing for runtime updates (e.g., during hot-reloading), JSPG uses an **Atomic Swap** pattern:
|
||||
1. **Parser Phase**: Schema JSONs are parsed into ordered `Schema` structs.
|
||||
2. **Compiler Phase**: The database iterates all parsed schemas and pre-computes native optimization maps (Descendants Map, Depths Map, Variations Map).
|
||||
3. **Immutable Validator**: The `Validator` struct immutably owns the `Database` registry and all its global maps. Schemas themselves are completely frozen; `$ref` strings are resolved dynamically at runtime using pre-computed O(1) maps.
|
||||
3. **Immutable AST Caching**: The `Validator` struct immutably owns the `Database` registry. Schemas themselves are frozen structurally, but utilize `OnceLock` interior mutability during the Compilation Phase to permanently cache resolved `$ref` inheritances, properties, and `compiled_edges` directly onto their AST nodes. This guarantees strict `O(1)` relationship and property validation execution at runtime without locking or recursive DB polling.
|
||||
4. **Lock-Free Reads**: Incoming operations acquire a read lock just long enough to clone the `Arc` inside an `RwLock<Option<Arc<Validator>>>`, ensuring zero blocking during schema updates.
|
||||
|
||||
### Global API Reference
|
||||
These functions operate on the global `GLOBAL_JSPG` engine instance and provide administrative boundaries:
|
||||
|
||||
* `jspg_setup(database jsonb) -> jsonb`: Initializes the engine. Deserializes the full database schema registry (types, enums, puncs, relations) from Postgres and compiles them into memory atomically.
|
||||
* `jspg_teardown() -> jsonb`: Clears the current session's engine instance from `GLOBAL_JSPG`, resetting the cache.
|
||||
* `jspg_schemas() -> jsonb`: Exports the fully compiled AST snapshot (including all inherited dependencies) out of `GLOBAL_JSPG` into standard JSON Schema representations.
|
||||
|
||||
---
|
||||
|
||||
## 2. Validator
|
||||
@ -30,10 +37,7 @@ To support high-throughput operations while allowing for runtime updates (e.g.,
|
||||
The Validator provides strict, schema-driven evaluation for the "Punc" architecture.
|
||||
|
||||
### API Reference
|
||||
* `jspg_setup(database jsonb) -> jsonb`: Loads and compiles the entire registry (types, enums, puncs, relations) atomically.
|
||||
* `mask_json_schema(schema_id text, instance jsonb) -> jsonb`: Validates and prunes unknown properties dynamically, returning masked data.
|
||||
* `jspg_validate(schema_id text, instance jsonb) -> jsonb`: Returns boolean-like success or structured errors.
|
||||
* `jspg_teardown() -> jsonb`: Clears the current session's schema cache.
|
||||
* `jspg_validate(schema_id text, instance jsonb) -> jsonb`: Validates the `instance` JSON payload strictly against the constraints of the registered `schema_id`. Returns boolean-like success or structured error codes.
|
||||
|
||||
### Custom Features & Deviations
|
||||
JSPG implements specific extensions to the Draft 2020-12 standard to support the Punc architecture's object-oriented needs while heavily optimizing for zero-runtime lookups.
|
||||
@ -69,11 +73,14 @@ To simplify frontend form validation, format validators specifically for `uuid`,
|
||||
|
||||
## 3. Merger
|
||||
|
||||
The Merger provides an automated, high-performance graph synchronization engine via the `jspg_merge(cue JSONB)` API. It orchestrates the complex mapping of nested JSON objects into normalized Postgres relational tables, honoring all inheritance and graph constraints.
|
||||
The Merger provides an automated, high-performance graph synchronization engine. It orchestrates the complex mapping of nested JSON objects into normalized Postgres relational tables, honoring all inheritance and graph constraints.
|
||||
|
||||
### API Reference
|
||||
* `jspg_merge(schema_id text, data jsonb) -> jsonb`: Traverses the provided JSON payload according to the compiled relational map of `schema_id`. Dynamically builds and executes relational SQL UPSERT paths natively.
|
||||
|
||||
### Core Features
|
||||
|
||||
* **Caching Strategy**: The Merger leverages the `Validator`'s in-memory `Database` registry to instantly resolve Foreign Key mapping graphs. It additionally utilizes the concurrent `GLOBAL_JSPG` application memory (`DashMap`) to cache statically constructed SQL `SELECT` strings used during deduplication (`lk_`) and difference tracking calculations.
|
||||
* **Caching Strategy**: The Merger leverages the native `compiled_edges` permanently cached onto the Schema AST via `OnceLock` to instantly resolve Foreign Key mapping graphs natively in absolute `O(1)` time. It additionally utilizes the concurrent `GLOBAL_JSPG` application memory (`DashMap`) to cache statically constructed SQL `SELECT` strings used during deduplication (`lk_`) and difference tracking calculations.
|
||||
* **Deep Graph Merging**: The Merger walks arbitrary levels of deeply nested JSON schemas (e.g. tracking an `order`, its `customer`, and an array of its `lines`). It intelligently discovers the correct parent-to-child or child-to-parent Foreign Keys stored in the registry and automatically maps the UUIDs across the relationships during UPSERT.
|
||||
* **Prefix Foreign Key Matching**: Handles scenario where multiple relations point to the same table by using database Foreign Key constraint prefixes (`fk_`). For example, if a schema has `shipping_address` and `billing_address`, the merger resolves against `fk_shipping_address_entity` vs `fk_billing_address_entity` automatically to correctly route object properties.
|
||||
* **Dynamic Deduplication & Lookups**: If a nested object is provided without an `id`, the Merger utilizes Postgres `lk_` index constraints defined in the schema registry (e.g. `lk_person` mapped to `first_name` and `last_name`). It dynamically queries these unique matching constraints to discover the correct UUID to perform an UPDATE, preventing data duplication.
|
||||
@ -91,7 +98,10 @@ The Merger provides an automated, high-performance graph synchronization engine
|
||||
|
||||
## 4. Queryer
|
||||
|
||||
The Queryer transforms Postgres into a pre-compiled Semantic Query Engine via the `jspg_query(schema_id text, cue jsonb)` API, designed to serve the exact shape of Punc responses directly via SQL.
|
||||
The Queryer transforms Postgres into a pre-compiled Semantic Query Engine, designed to serve the exact shape of Punc responses directly via SQL.
|
||||
|
||||
### API Reference
|
||||
* `jspg_query(schema_id text, filters jsonb) -> jsonb`: Compiles the JSON Schema AST of `schema_id` directly into pre-planned, nested multi-JOIN SQL execution trees. Processes `filters` structurally.
|
||||
|
||||
### Core Features
|
||||
|
||||
@ -108,22 +118,11 @@ The Queryer transforms Postgres into a pre-compiled Semantic Query Engine via th
|
||||
* **Multi-Table Branching**: If the Physical Table is a parent to other tables (e.g. `organization` has variations `["organization", "bot", "person"]`), the compiler generates a dynamic `CASE WHEN type = '...' THEN ...` query, expanding into `JOIN`s for each variation.
|
||||
* **Single-Table Bypass**: If the Physical Table is a leaf node with only one variation (e.g. `person` has variations `["person"]`), the compiler cleanly bypasses `CASE` generation and compiles a simple `SELECT` across the base table, as all schema extensions (e.g. `light.person`, `full.person`) are guaranteed to reside in the exact same physical row.
|
||||
|
||||
### The Stem Engine
|
||||
### Ad-Hoc Schema Promotion
|
||||
|
||||
Rather than over-fetching heavy Entity payloads and trimming them, Punc Framework Websockets depend on isolated subgraphs defined as **Stems**.
|
||||
A `Stem` is a declaration of an **Entity Type boundary** that exists somewhere within the compiled JSON Schema graph, expressed using **`gjson` multipath syntax** (e.g., `contacts.#.phone_numbers.#`).
|
||||
|
||||
Because `pg_notify` (Beats) fire rigidly from physical Postgres tables (e.g. `{"type": "phone_number"}`), the Go Framework only ever needs to know: "Does the schema `with_contacts.person` contain the `phone_number` Entity anywhere inside its tree, and if so, what is the gjson path to iterate its payload?"
|
||||
|
||||
* **Initialization:** During startup (`jspg_stems()`), the database crawls all Schemas and maps out every physical Entity Type it references. It builds a highly optimized `HashMap<String, HashMap<String, Arc<Stem>>>` providing strictly `O(1)` memory lookups mapping `Schema ID -> { Stem Path -> Entity Type }`.
|
||||
* **GJSON Pathing:** Unlike standard JSON Pointers, stems utilize `.#` array iterator syntax. The Go web server consumes this native path (e.g. `lines.#`) across the raw Postgres JSON byte payload, extracting all active UUIDs in one massive sub-millisecond sweep without unmarshaling Go ASTs.
|
||||
* **Polymorphic Condition Selectors:** When trailing paths would otherwise collide because of abstract polymorphic type definitions (e.g., a `target` property bounded by a `oneOf` taking either `phone_number` or `email_address`), JSPG natively appends evaluated `gjson` type conditions into the path (e.g. `contacts.#.target#(type=="phone_number")`). This guarantees `O(1)` key uniqueness in the HashMap while retaining extreme array extraction speeds natively without runtime AST evaluation.
|
||||
* **Identifier Prioritization:** When determining if a nested object boundary is an Entity, JSPG natively prioritizes defined `$id` tags over `$ref` inheritance pointers to prevent polymorphic boundaries from devolving into their generic base classes.
|
||||
* **Cyclical Deduplication:** Because Punc relationships often reference back on themselves via deeply nested classes, the Stem Engine applies intelligent path deduplication. If the active `current_path` already ends with the target entity string, it traverses the inheritance properties without appending the entity to the stem path again, eliminating infinite powerset loops.
|
||||
* **Relationship Path Squashing:** When calculating string paths structurally, JSPG intentionally **omits** properties natively named `target` or `source` if they belong to a native database `relationship` table override.
|
||||
* **The Go Router**: The Golang Punc framework uses this exact mapping to register WebSocket Beat frequencies exclusively on the Entity types discovered.
|
||||
* **The Queryer Execution**: When the Go framework asks JSPG to hydrate a partial `phone_number` stem for the `with_contacts.person` schema, instead of jumping through string paths, the SQL Compiler simply reaches into the Schema's AST using the `phone_number` Type string, pulls out exactly that entity's mapping rules, and returns a fully correlated `SELECT` block! This natively handles nested array properties injected via `oneOf` or array references efficiently bypassing runtime powerset expansion.
|
||||
* **Performance:** These Stem execution structures are fully statically compiled via SPI and map perfectly to `O(1)` real-time routing logic on the application tier.
|
||||
To seamlessly support deeply nested, inline Object definitions that don't declare an explicit `$id`, JSPG aggressively promotes them to standalone topological entities during the database compilation phase.
|
||||
* **Hash Generation:** While evaluating the unified graph, if the compiler enters an `Object` or `Array` structure completely lacking an `$id`, it dynamically calculates a localized hash alias representing exactly its structural constraints.
|
||||
* **Promotion:** This inline chunk is mathematically elevated to its own `$id` in the `db.schemas` cache registry. This guarantees that $O(1)$ WebSockets or isolated queries can natively target any arbitrary sub-object of a massive database topology directly without recursively re-parsing its parent's AST block every read.
|
||||
|
||||
## 5. Testing & Execution Architecture
|
||||
|
||||
|
||||
58
LOOKUP_VERIFICATION.md
Normal file
58
LOOKUP_VERIFICATION.md
Normal file
@ -0,0 +1,58 @@
|
||||
# The Postgres Partial Index Claiming Pattern
|
||||
|
||||
This document outlines the architectural strategy for securely handling the deduplication, claiming, and verification of sensitive unique identifiers (like email addresses or phone numbers) strictly through PostgreSQL without requiring "magical" logic in the JSPG `Merger`.
|
||||
|
||||
## The Denial of Service (DoS) Squatter Problem
|
||||
|
||||
If you enforce a standard `UNIQUE` constraint on an email address table:
|
||||
1. Malicious User A signs up and adds `jeff.bezos@amazon.com` to their account but never verifies it.
|
||||
2. The real Jeff Bezos signs up.
|
||||
3. The Database blocks Jeff because the unique string already exists.
|
||||
|
||||
The squatter has effectively locked the legitimate owner out of the system.
|
||||
|
||||
## The Anti-Patterns
|
||||
|
||||
1. **Global Entity Flags**: Adding a global `verified` boolean to the root `entity` table forces unrelated objects (like Widgets, Invoices, Orders) to carry verification logic that doesn't belong to them.
|
||||
2. **Magical Merger Logic**: Making JSPG's `Merger` aware of a specific `verified` field breaks its pure structural translation model. The Merger shouldn't need hardcoded conditional logic to know if it's allowed to update an unverified row.
|
||||
|
||||
## The Solution: Postgres Partial Unique Indexes
|
||||
|
||||
The holy grail is to defer all claiming logic natively to the database engine using a **Partial Unique Index**.
|
||||
|
||||
```sql
|
||||
-- Remove any existing global unique constraint on address first
|
||||
CREATE UNIQUE INDEX lk_email_address_verified
|
||||
ON email_address (address)
|
||||
WHERE verified_at IS NOT NULL;
|
||||
```
|
||||
|
||||
### How the Lifecycle Works Natively
|
||||
|
||||
1. **Unverified Squatters (Isolated Rows):**
|
||||
A hundred different users can send `{ "address": "jeff.bezos@amazon.com" }` through the `save_person` Punc. Because the Punc isolates them and doesn't allow setting the `verified_at` property natively (enforced by the JSON schema), the JSPG Merger inserts `NULL`.
|
||||
Postgres permits all 100 `INSERT` commands to succeed because the Partial Index **ignores** rows where `verified_at IS NULL`. Every user gets their own isolated, unverified row acting as a placeholder on their contact edge.
|
||||
|
||||
2. **The Verification Race (The Claim):**
|
||||
The real Jeff clicks his magic verification link. The backend securely executes a specific verification Punc that runs:
|
||||
`UPDATE email_address SET verified_at = now() WHERE id = <jeff's-real-uuid>`
|
||||
|
||||
3. **The Lockout:**
|
||||
Because Jeff's row now strictly satisfies `verified_at IS NOT NULL`, that exact row enters the Partial Unique Index.
|
||||
If any of the other 99 squatters *ever* click their fake verification links (or if a new user tries to verify that same email), PostgreSQL hits the index and violently throws a **Unique Constraint Violation**, flawlessly blocking them. The winner has permanently claimed the slot across the entire environment!
|
||||
|
||||
### Periodic Cleanup
|
||||
|
||||
Since unverified rows are allowed to accumulate without colliding, a simple Postgres `pg_cron` job or backend worker can sweep the table nightly to prune abandoned claims and preserve storage:
|
||||
|
||||
```sql
|
||||
DELETE FROM email_address
|
||||
WHERE verified_at IS NULL
|
||||
AND created_at < NOW() - INTERVAL '24 hours';
|
||||
```
|
||||
|
||||
### Why this is the Ultimate Architecture
|
||||
|
||||
* The **JSPG Merger** remains mathematically pure. It doesn't know what `verified_at` is; it simply respects the database's structural limits (`O(1)` pure translation).
|
||||
* **Row-Level Security (RLS)** naturally blocks users from seeing or claiming each other's unverified rows.
|
||||
* You offload complex race-condition tracking entirely to the C-level PostgreSQL B-Tree indexing engine, guaranteeing absolute cluster-wide atomicity.
|
||||
@ -142,7 +142,7 @@
|
||||
"errors": [
|
||||
{
|
||||
"code": "CONST_VIOLATED",
|
||||
"path": "/con"
|
||||
"path": "con"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
"errors": [
|
||||
{
|
||||
"code": "TYPE_MISMATCH",
|
||||
"path": "/base_prop"
|
||||
"path": "base_prop"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -109,7 +109,7 @@
|
||||
"errors": [
|
||||
{
|
||||
"code": "REQUIRED_FIELD_MISSING",
|
||||
"path": "/a"
|
||||
"path": "a"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -126,7 +126,7 @@
|
||||
"errors": [
|
||||
{
|
||||
"code": "REQUIRED_FIELD_MISSING",
|
||||
"path": "/b"
|
||||
"path": "b"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -196,7 +196,7 @@
|
||||
"errors": [
|
||||
{
|
||||
"code": "DEPENDENCY_FAILED",
|
||||
"path": "/base_dep"
|
||||
"path": "base_dep"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -214,7 +214,7 @@
|
||||
"errors": [
|
||||
{
|
||||
"code": "DEPENDENCY_FAILED",
|
||||
"path": "/child_dep"
|
||||
"path": "child_dep"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1094
fixtures/merger.json
1094
fixtures/merger.json
File diff suppressed because it is too large
Load Diff
214
fixtures/paths.json
Normal file
214
fixtures/paths.json
Normal file
@ -0,0 +1,214 @@
|
||||
[
|
||||
{
|
||||
"description": "Hybrid Array Pathing",
|
||||
"database": {
|
||||
"schemas": [
|
||||
{
|
||||
"$id": "hybrid_pathing",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"primitives": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ad_hoc_objects": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"minimum": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deep_entities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"nested": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"flag": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "happy path passes structural validation",
|
||||
"data": {
|
||||
"primitives": [
|
||||
"a",
|
||||
"b"
|
||||
],
|
||||
"ad_hoc_objects": [
|
||||
{
|
||||
"name": "obj1"
|
||||
}
|
||||
],
|
||||
"entities": [
|
||||
{
|
||||
"id": "entity-1",
|
||||
"value": 15
|
||||
}
|
||||
],
|
||||
"deep_entities": [
|
||||
{
|
||||
"id": "parent-1",
|
||||
"nested": [
|
||||
{
|
||||
"id": "child-1",
|
||||
"flag": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"schema_id": "hybrid_pathing",
|
||||
"action": "validate",
|
||||
"expect": {
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "primitive arrays use numeric indexing",
|
||||
"data": {
|
||||
"primitives": [
|
||||
"a",
|
||||
123
|
||||
]
|
||||
},
|
||||
"schema_id": "hybrid_pathing",
|
||||
"action": "validate",
|
||||
"expect": {
|
||||
"success": false,
|
||||
"errors": [
|
||||
{
|
||||
"code": "INVALID_TYPE",
|
||||
"path": "primitives/1"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "ad-hoc objects without ids use numeric indexing",
|
||||
"data": {
|
||||
"ad_hoc_objects": [
|
||||
{
|
||||
"name": "valid"
|
||||
},
|
||||
{
|
||||
"age": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
"schema_id": "hybrid_pathing",
|
||||
"action": "validate",
|
||||
"expect": {
|
||||
"success": false,
|
||||
"errors": [
|
||||
{
|
||||
"code": "REQUIRED_FIELD_MISSING",
|
||||
"path": "ad_hoc_objects/1/name"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "arrays of objects with ids use topological uuid indexing",
|
||||
"data": {
|
||||
"entities": [
|
||||
{
|
||||
"id": "entity-alpha",
|
||||
"value": 20
|
||||
},
|
||||
{
|
||||
"id": "entity-beta",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
"schema_id": "hybrid_pathing",
|
||||
"action": "validate",
|
||||
"expect": {
|
||||
"success": false,
|
||||
"errors": [
|
||||
{
|
||||
"code": "MINIMUM_VIOLATED",
|
||||
"path": "entities/entity-beta/value"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "deeply nested entity arrays retain full topological paths",
|
||||
"data": {
|
||||
"deep_entities": [
|
||||
{
|
||||
"id": "parent-omega",
|
||||
"nested": [
|
||||
{
|
||||
"id": "child-alpha",
|
||||
"flag": true
|
||||
},
|
||||
{
|
||||
"id": "child-beta",
|
||||
"flag": "invalid-string"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"schema_id": "hybrid_pathing",
|
||||
"action": "validate",
|
||||
"expect": {
|
||||
"success": false,
|
||||
"errors": [
|
||||
{
|
||||
"code": "INVALID_TYPE",
|
||||
"path": "deep_entities/parent-omega/nested/child-beta/flag"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -20,6 +20,16 @@
|
||||
"$family": "base.person"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_orders",
|
||||
"schemas": [
|
||||
{
|
||||
"$id": "get_orders.response",
|
||||
"type": "array",
|
||||
"items": { "$ref": "light.order" }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"enums": [],
|
||||
@ -664,6 +674,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$id": "light.order",
|
||||
"$ref": "order",
|
||||
"properties": {
|
||||
"customer": {
|
||||
"$ref": "base.person"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$id": "full.order",
|
||||
"$ref": "order",
|
||||
@ -858,27 +877,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Simple entity select on root stem",
|
||||
"action": "query",
|
||||
"schema_id": "entity",
|
||||
"stem": "",
|
||||
"expect": {
|
||||
"success": true,
|
||||
"sql": [
|
||||
[
|
||||
"(SELECT jsonb_build_object(",
|
||||
" 'archived', entity_1.archived,",
|
||||
" 'created_at', entity_1.created_at,",
|
||||
" 'id', entity_1.id,",
|
||||
" 'name', entity_1.name,",
|
||||
" 'type', entity_1.type)",
|
||||
"FROM agreego.entity entity_1",
|
||||
"WHERE NOT entity_1.archived)"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Simple entity select with multiple filters",
|
||||
"action": "query",
|
||||
@ -1016,7 +1014,7 @@
|
||||
" JOIN agreego.entity entity_8 ON entity_8.id = address_7.id",
|
||||
" WHERE",
|
||||
" NOT entity_8.archived",
|
||||
" AND relationship_5.target_id = address_7.id),",
|
||||
" AND relationship_5.target_id = entity_8.id),",
|
||||
" 'type', entity_6.type",
|
||||
" )), '[]'::jsonb)",
|
||||
" FROM agreego.contact contact_4",
|
||||
@ -1024,7 +1022,8 @@
|
||||
" JOIN agreego.entity entity_6 ON entity_6.id = relationship_5.id",
|
||||
" WHERE",
|
||||
" NOT entity_6.archived",
|
||||
" AND contact_4.parent_id = entity_3.id),",
|
||||
" AND relationship_5.target_type = 'address'",
|
||||
" AND relationship_5.source_id = entity_3.id),",
|
||||
" 'age', person_1.age,",
|
||||
" 'archived', entity_3.archived,",
|
||||
" 'contacts',",
|
||||
@ -1048,7 +1047,7 @@
|
||||
" JOIN agreego.entity entity_17 ON entity_17.id = address_16.id",
|
||||
" WHERE",
|
||||
" NOT entity_17.archived",
|
||||
" AND relationship_10.target_id = address_16.id))",
|
||||
" AND relationship_10.target_id = entity_17.id))",
|
||||
" WHEN entity_11.target_type = 'email_address' THEN",
|
||||
" ((SELECT jsonb_build_object(",
|
||||
" 'address', email_address_14.address,",
|
||||
@ -1062,7 +1061,7 @@
|
||||
" JOIN agreego.entity entity_15 ON entity_15.id = email_address_14.id",
|
||||
" WHERE",
|
||||
" NOT entity_15.archived",
|
||||
" AND relationship_10.target_id = email_address_14.id))",
|
||||
" AND relationship_10.target_id = entity_15.id))",
|
||||
" WHEN entity_11.target_type = 'phone_number' THEN",
|
||||
" ((SELECT jsonb_build_object(",
|
||||
" 'archived', entity_13.archived,",
|
||||
@ -1076,7 +1075,7 @@
|
||||
" JOIN agreego.entity entity_13 ON entity_13.id = phone_number_12.id",
|
||||
" WHERE",
|
||||
" NOT entity_13.archived",
|
||||
" AND relationship_10.target_id = phone_number_12.id))",
|
||||
" AND relationship_10.target_id = entity_13.id))",
|
||||
" ELSE NULL END,",
|
||||
" 'type', entity_11.type",
|
||||
" )), '[]'::jsonb)",
|
||||
@ -1085,7 +1084,7 @@
|
||||
" JOIN agreego.entity entity_11 ON entity_11.id = relationship_10.id",
|
||||
" WHERE",
|
||||
" NOT entity_11.archived",
|
||||
" AND contact_9.parent_id = entity_3.id),",
|
||||
" AND relationship_10.source_id = entity_3.id),",
|
||||
" 'created_at', entity_3.created_at,",
|
||||
" 'email_addresses',",
|
||||
" (SELECT COALESCE(jsonb_agg(jsonb_build_object(",
|
||||
@ -1107,7 +1106,7 @@
|
||||
" JOIN agreego.entity entity_22 ON entity_22.id = email_address_21.id",
|
||||
" WHERE",
|
||||
" NOT entity_22.archived",
|
||||
" AND relationship_19.target_id = email_address_21.id),",
|
||||
" AND relationship_19.target_id = entity_22.id),",
|
||||
" 'type', entity_20.type",
|
||||
" )), '[]'::jsonb)",
|
||||
" FROM agreego.contact contact_18",
|
||||
@ -1115,7 +1114,8 @@
|
||||
" JOIN agreego.entity entity_20 ON entity_20.id = relationship_19.id",
|
||||
" WHERE",
|
||||
" NOT entity_20.archived",
|
||||
" AND contact_18.parent_id = entity_3.id),",
|
||||
" AND relationship_19.target_type = 'email_address'",
|
||||
" AND relationship_19.source_id = entity_3.id),",
|
||||
" 'first_name', person_1.first_name,",
|
||||
" 'id', entity_3.id,",
|
||||
" 'last_name', person_1.last_name,",
|
||||
@ -1140,7 +1140,7 @@
|
||||
" JOIN agreego.entity entity_27 ON entity_27.id = phone_number_26.id",
|
||||
" WHERE",
|
||||
" NOT entity_27.archived",
|
||||
" AND relationship_24.target_id = phone_number_26.id),",
|
||||
" AND relationship_24.target_id = entity_27.id),",
|
||||
" 'type', entity_25.type",
|
||||
" )), '[]'::jsonb)",
|
||||
" FROM agreego.contact contact_23",
|
||||
@ -1148,7 +1148,8 @@
|
||||
" JOIN agreego.entity entity_25 ON entity_25.id = relationship_24.id",
|
||||
" WHERE",
|
||||
" NOT entity_25.archived",
|
||||
" AND contact_23.parent_id = entity_3.id),",
|
||||
" AND relationship_24.target_type = 'phone_number'",
|
||||
" AND relationship_24.source_id = entity_3.id),",
|
||||
" 'type', entity_3.type",
|
||||
")",
|
||||
"FROM agreego.person person_1",
|
||||
@ -1184,7 +1185,7 @@
|
||||
"$eq": true,
|
||||
"$ne": false
|
||||
},
|
||||
"contacts.#.is_primary": {
|
||||
"contacts/is_primary": {
|
||||
"$eq": true
|
||||
},
|
||||
"created_at": {
|
||||
@ -1224,7 +1225,7 @@
|
||||
"$eq": "%Doe%",
|
||||
"$ne": "%Smith%"
|
||||
},
|
||||
"phone_numbers.#.target.number": {
|
||||
"phone_numbers/target/number": {
|
||||
"$eq": "555-1234"
|
||||
}
|
||||
},
|
||||
@ -1253,7 +1254,7 @@
|
||||
" JOIN agreego.entity entity_8 ON entity_8.id = address_7.id",
|
||||
" WHERE",
|
||||
" NOT entity_8.archived",
|
||||
" AND relationship_5.target_id = address_7.id),",
|
||||
" AND relationship_5.target_id = entity_8.id),",
|
||||
" 'type', entity_6.type",
|
||||
" )), '[]'::jsonb)",
|
||||
" FROM agreego.contact contact_4",
|
||||
@ -1261,7 +1262,8 @@
|
||||
" JOIN agreego.entity entity_6 ON entity_6.id = relationship_5.id",
|
||||
" WHERE",
|
||||
" NOT entity_6.archived",
|
||||
" AND contact_4.parent_id = entity_3.id),",
|
||||
" AND relationship_5.target_type = 'address'",
|
||||
" AND relationship_5.source_id = entity_3.id),",
|
||||
" 'age', person_1.age,",
|
||||
" 'archived', entity_3.archived,",
|
||||
" 'contacts',",
|
||||
@ -1285,7 +1287,7 @@
|
||||
" JOIN agreego.entity entity_17 ON entity_17.id = address_16.id",
|
||||
" WHERE",
|
||||
" NOT entity_17.archived",
|
||||
" AND relationship_10.target_id = address_16.id))",
|
||||
" AND relationship_10.target_id = entity_17.id))",
|
||||
" WHEN entity_11.target_type = 'email_address' THEN",
|
||||
" ((SELECT jsonb_build_object(",
|
||||
" 'address', email_address_14.address,",
|
||||
@ -1299,7 +1301,7 @@
|
||||
" JOIN agreego.entity entity_15 ON entity_15.id = email_address_14.id",
|
||||
" WHERE",
|
||||
" NOT entity_15.archived",
|
||||
" AND relationship_10.target_id = email_address_14.id))",
|
||||
" AND relationship_10.target_id = entity_15.id))",
|
||||
" WHEN entity_11.target_type = 'phone_number' THEN",
|
||||
" ((SELECT jsonb_build_object(",
|
||||
" 'archived', entity_13.archived,",
|
||||
@ -1313,7 +1315,7 @@
|
||||
" JOIN agreego.entity entity_13 ON entity_13.id = phone_number_12.id",
|
||||
" WHERE",
|
||||
" NOT entity_13.archived",
|
||||
" AND relationship_10.target_id = phone_number_12.id))",
|
||||
" AND relationship_10.target_id = entity_13.id))",
|
||||
" ELSE NULL END,",
|
||||
" 'type', entity_11.type",
|
||||
" )), '[]'::jsonb)",
|
||||
@ -1323,7 +1325,7 @@
|
||||
" WHERE",
|
||||
" NOT entity_11.archived",
|
||||
" AND contact_9.is_primary = ($11#>>'{}')::boolean",
|
||||
" AND contact_9.parent_id = entity_3.id),",
|
||||
" AND relationship_10.source_id = entity_3.id),",
|
||||
" 'created_at', entity_3.created_at,",
|
||||
" 'email_addresses',",
|
||||
" (SELECT COALESCE(jsonb_agg(jsonb_build_object(",
|
||||
@ -1345,7 +1347,7 @@
|
||||
" JOIN agreego.entity entity_22 ON entity_22.id = email_address_21.id",
|
||||
" WHERE",
|
||||
" NOT entity_22.archived",
|
||||
" AND relationship_19.target_id = email_address_21.id),",
|
||||
" AND relationship_19.target_id = entity_22.id),",
|
||||
" 'type', entity_20.type",
|
||||
" )), '[]'::jsonb)",
|
||||
" FROM agreego.contact contact_18",
|
||||
@ -1353,7 +1355,8 @@
|
||||
" JOIN agreego.entity entity_20 ON entity_20.id = relationship_19.id",
|
||||
" WHERE",
|
||||
" NOT entity_20.archived",
|
||||
" AND contact_18.parent_id = entity_3.id),",
|
||||
" AND relationship_19.target_type = 'email_address'",
|
||||
" AND relationship_19.source_id = entity_3.id),",
|
||||
" 'first_name', person_1.first_name,",
|
||||
" 'id', entity_3.id,",
|
||||
" 'last_name', person_1.last_name,",
|
||||
@ -1379,7 +1382,7 @@
|
||||
" WHERE",
|
||||
" NOT entity_27.archived",
|
||||
" AND phone_number_26.number ILIKE $32#>>'{}'",
|
||||
" AND relationship_24.target_id = phone_number_26.id),",
|
||||
" AND relationship_24.target_id = entity_27.id),",
|
||||
" 'type', entity_25.type",
|
||||
" )), '[]'::jsonb)",
|
||||
" FROM agreego.contact contact_23",
|
||||
@ -1387,7 +1390,8 @@
|
||||
" JOIN agreego.entity entity_25 ON entity_25.id = relationship_24.id",
|
||||
" WHERE",
|
||||
" NOT entity_25.archived",
|
||||
" AND contact_23.parent_id = entity_3.id),",
|
||||
" AND relationship_24.target_type = 'phone_number'",
|
||||
" AND relationship_24.source_id = entity_3.id),",
|
||||
" 'type', entity_3.type",
|
||||
")",
|
||||
"FROM agreego.person person_1",
|
||||
@ -1430,10 +1434,9 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Full person stem query on phone number contact",
|
||||
"description": "Person ad-hoc email addresses select",
|
||||
"action": "query",
|
||||
"schema_id": "full.person",
|
||||
"stem": "phone_numbers.#",
|
||||
"schema_id": "full.person/email_addresses",
|
||||
"expect": {
|
||||
"success": true,
|
||||
"sql": [
|
||||
@ -1446,73 +1449,26 @@
|
||||
" 'name', entity_3.name,",
|
||||
" 'target',",
|
||||
" (SELECT jsonb_build_object(",
|
||||
" 'address', email_address_4.address,",
|
||||
" 'archived', entity_5.archived,",
|
||||
" 'created_at', entity_5.created_at,",
|
||||
" 'id', entity_5.id,",
|
||||
" 'name', entity_5.name,",
|
||||
" 'number', phone_number_4.number,",
|
||||
" 'type', entity_5.type",
|
||||
" )",
|
||||
" FROM agreego.phone_number phone_number_4",
|
||||
" JOIN agreego.entity entity_5 ON entity_5.id = phone_number_4.id",
|
||||
" FROM agreego.email_address email_address_4",
|
||||
" JOIN agreego.entity entity_5 ON entity_5.id = email_address_4.id",
|
||||
" WHERE",
|
||||
" NOT entity_5.archived",
|
||||
" AND relationship_2.target_id = phone_number_4.id",
|
||||
" ),",
|
||||
" AND relationship_2.target_id = entity_5.id),",
|
||||
" 'type', entity_3.type",
|
||||
")",
|
||||
"FROM agreego.contact contact_1",
|
||||
"JOIN agreego.relationship relationship_2 ON relationship_2.id = contact_1.id",
|
||||
"JOIN agreego.entity entity_3 ON entity_3.id = relationship_2.id",
|
||||
"WHERE NOT entity_3.archived)"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Full person stem query on phone number contact on phone number",
|
||||
"action": "query",
|
||||
"schema_id": "full.person",
|
||||
"stem": "phone_numbers.#.target",
|
||||
"expect": {
|
||||
"success": true,
|
||||
"sql": [
|
||||
[
|
||||
"(SELECT jsonb_build_object(",
|
||||
" 'archived', entity_2.archived,",
|
||||
" 'created_at', entity_2.created_at,",
|
||||
" 'id', entity_2.id,",
|
||||
" 'name', entity_2.name,",
|
||||
" 'number', phone_number_1.number,",
|
||||
" 'type', entity_2.type",
|
||||
")",
|
||||
"FROM agreego.phone_number phone_number_1",
|
||||
"JOIN agreego.entity entity_2 ON entity_2.id = phone_number_1.id",
|
||||
"WHERE NOT entity_2.archived)"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Full person stem query on contact email address",
|
||||
"action": "query",
|
||||
"schema_id": "full.person",
|
||||
"stem": "contacts.#.target#(type==\"email_address\")",
|
||||
"expect": {
|
||||
"success": true,
|
||||
"sql": [
|
||||
[
|
||||
"(SELECT jsonb_build_object(",
|
||||
" 'address', email_address_1.address,",
|
||||
" 'archived', entity_2.archived,",
|
||||
" 'created_at', entity_2.created_at,",
|
||||
" 'id', entity_2.id,",
|
||||
" 'name', entity_2.name,",
|
||||
" 'type', entity_2.type",
|
||||
")",
|
||||
"FROM agreego.email_address email_address_1",
|
||||
"JOIN agreego.entity entity_2 ON entity_2.id = email_address_1.id",
|
||||
"WHERE NOT entity_2.archived)"
|
||||
"WHERE",
|
||||
" NOT entity_3.archived",
|
||||
" AND relationship_2.target_type = 'email_address')"
|
||||
]
|
||||
]
|
||||
}
|
||||
@ -1632,6 +1588,47 @@
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Root Array SQL evaluation for Order fetching Light Order",
|
||||
"action": "query",
|
||||
"schema_id": "get_orders.response",
|
||||
"expect": {
|
||||
"success": true,
|
||||
"sql": [
|
||||
[
|
||||
"(SELECT COALESCE(jsonb_agg(jsonb_build_object(",
|
||||
" 'archived', entity_2.archived,",
|
||||
" 'created_at', entity_2.created_at,",
|
||||
" 'customer',",
|
||||
" (SELECT jsonb_build_object(",
|
||||
" 'age', person_3.age,",
|
||||
" 'archived', entity_5.archived,",
|
||||
" 'created_at', entity_5.created_at,",
|
||||
" 'first_name', person_3.first_name,",
|
||||
" 'id', entity_5.id,",
|
||||
" 'last_name', person_3.last_name,",
|
||||
" 'name', entity_5.name,",
|
||||
" 'type', entity_5.type",
|
||||
" )",
|
||||
" FROM agreego.person person_3",
|
||||
" JOIN agreego.organization organization_4 ON organization_4.id = person_3.id",
|
||||
" JOIN agreego.entity entity_5 ON entity_5.id = organization_4.id",
|
||||
" WHERE",
|
||||
" NOT entity_5.archived",
|
||||
" AND order_1.customer_id = person_3.id),",
|
||||
" 'customer_id', order_1.customer_id,",
|
||||
" 'id', entity_2.id,",
|
||||
" 'name', entity_2.name,",
|
||||
" 'total', order_1.total,",
|
||||
" 'type', entity_2.type",
|
||||
")), '[]'::jsonb)",
|
||||
"FROM agreego.order order_1",
|
||||
"JOIN agreego.entity entity_2 ON entity_2.id = order_1.id",
|
||||
"WHERE NOT entity_2.archived)"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -677,7 +677,7 @@
|
||||
"errors": [
|
||||
{
|
||||
"code": "TYPE_MISMATCH",
|
||||
"path": "/type"
|
||||
"path": "type"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -782,7 +782,7 @@
|
||||
"errors": [
|
||||
{
|
||||
"code": "TYPE_MISMATCH",
|
||||
"path": "/type"
|
||||
"path": "type"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,312 +0,0 @@
|
||||
[
|
||||
{
|
||||
"description": "Stem Engine Unit Tests",
|
||||
"database": {
|
||||
"puncs": [],
|
||||
"enums": [],
|
||||
"relations": [
|
||||
{
|
||||
"id": "rel1",
|
||||
"type": "relation",
|
||||
"constraint": "fk_contact_entity",
|
||||
"source_type": "contact",
|
||||
"source_columns": [
|
||||
"entity_id"
|
||||
],
|
||||
"destination_type": "person",
|
||||
"destination_columns": [
|
||||
"id"
|
||||
],
|
||||
"prefix": null
|
||||
},
|
||||
{
|
||||
"id": "rel2",
|
||||
"type": "relation",
|
||||
"constraint": "fk_relationship_target",
|
||||
"source_type": "relationship",
|
||||
"source_columns": [
|
||||
"target_id",
|
||||
"target_type"
|
||||
],
|
||||
"destination_type": "entity",
|
||||
"destination_columns": [
|
||||
"id",
|
||||
"type"
|
||||
],
|
||||
"prefix": "target"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
{
|
||||
"name": "entity",
|
||||
"hierarchy": [
|
||||
"entity"
|
||||
],
|
||||
"schemas": [
|
||||
{
|
||||
"$id": "entity",
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "person",
|
||||
"hierarchy": [
|
||||
"person",
|
||||
"entity"
|
||||
],
|
||||
"schemas": [
|
||||
{
|
||||
"$id": "person",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "email_address",
|
||||
"hierarchy": [
|
||||
"email_address",
|
||||
"entity"
|
||||
],
|
||||
"schemas": [
|
||||
{
|
||||
"$id": "email_address",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "phone_number",
|
||||
"hierarchy": [
|
||||
"phone_number",
|
||||
"entity"
|
||||
],
|
||||
"schemas": [
|
||||
{
|
||||
"$id": "phone_number",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "relationship",
|
||||
"relationship": true,
|
||||
"hierarchy": [
|
||||
"relationship",
|
||||
"entity"
|
||||
],
|
||||
"schemas": [
|
||||
{
|
||||
"$id": "relationship",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "contact",
|
||||
"relationship": true,
|
||||
"hierarchy": [
|
||||
"contact",
|
||||
"relationship",
|
||||
"entity"
|
||||
],
|
||||
"schemas": [
|
||||
{
|
||||
"$id": "contact",
|
||||
"$ref": "relationship",
|
||||
"properties": {
|
||||
"target": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "phone_number"
|
||||
},
|
||||
{
|
||||
"$ref": "email_address"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "save_person",
|
||||
"schemas": [
|
||||
{
|
||||
"$id": "save_person.response",
|
||||
"$ref": "person",
|
||||
"properties": {
|
||||
"contacts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "contact"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "correctly squashes deep oneOf refs through array paths",
|
||||
"action": "compile",
|
||||
"expect": {
|
||||
"success": true,
|
||||
"stems": {
|
||||
"contact": {
|
||||
"": {
|
||||
"schema": {
|
||||
"$id": "contact",
|
||||
"$ref": "relationship",
|
||||
"properties": {
|
||||
"target": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "phone_number"
|
||||
},
|
||||
{
|
||||
"$ref": "email_address"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "contact"
|
||||
},
|
||||
"target#(type==\"email_address\")": {
|
||||
"relation": "target_id",
|
||||
"schema": {
|
||||
"$id": "email_address",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
},
|
||||
"type": "email_address"
|
||||
},
|
||||
"target#(type==\"phone_number\")": {
|
||||
"relation": "target_id",
|
||||
"schema": {
|
||||
"$id": "phone_number",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
},
|
||||
"type": "phone_number"
|
||||
}
|
||||
},
|
||||
"email_address": {
|
||||
"": {
|
||||
"schema": {
|
||||
"$id": "email_address",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
},
|
||||
"type": "email_address"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"": {
|
||||
"schema": {
|
||||
"$id": "entity",
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "entity"
|
||||
}
|
||||
},
|
||||
"person": {
|
||||
"": {
|
||||
"schema": {
|
||||
"$id": "person",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
},
|
||||
"type": "person"
|
||||
}
|
||||
},
|
||||
"phone_number": {
|
||||
"": {
|
||||
"schema": {
|
||||
"$id": "phone_number",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
},
|
||||
"type": "phone_number"
|
||||
}
|
||||
},
|
||||
"relationship": {
|
||||
"": {
|
||||
"schema": {
|
||||
"$id": "relationship",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
},
|
||||
"type": "relationship"
|
||||
}
|
||||
},
|
||||
"save_person.response": {
|
||||
"": {
|
||||
"schema": {
|
||||
"$id": "save_person.response",
|
||||
"$ref": "person",
|
||||
"properties": {
|
||||
"contacts": {
|
||||
"items": {
|
||||
"$ref": "contact"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "person"
|
||||
},
|
||||
"contacts.#": {
|
||||
"relation": "contacts_id",
|
||||
"schema": {
|
||||
"$id": "contact",
|
||||
"$ref": "relationship",
|
||||
"properties": {
|
||||
"target": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "phone_number"
|
||||
},
|
||||
{
|
||||
"$ref": "email_address"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "contact"
|
||||
},
|
||||
"contacts.#.target#(type==\"email_address\")": {
|
||||
"relation": "target_id",
|
||||
"schema": {
|
||||
"$id": "email_address",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
},
|
||||
"type": "email_address"
|
||||
},
|
||||
"contacts.#.target#(type==\"phone_number\")": {
|
||||
"relation": "target_id",
|
||||
"schema": {
|
||||
"$id": "phone_number",
|
||||
"$ref": "entity",
|
||||
"properties": {}
|
||||
},
|
||||
"type": "phone_number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
7
src/database/edge.rs
Normal file
7
src/database/edge.rs
Normal file
@ -0,0 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Edge {
|
||||
pub constraint: String,
|
||||
pub forward: bool,
|
||||
}
|
||||
@ -124,42 +124,23 @@ fn parse_and_match_mocks(sql: &str, mocks: &[Value]) -> Option<Vec<Value>> {
|
||||
return None;
|
||||
};
|
||||
|
||||
// 2. Extract WHERE conditions
|
||||
let mut conditions = Vec::new();
|
||||
// 2. Extract WHERE conditions string
|
||||
let mut where_clause = String::new();
|
||||
if let Some(where_idx) = sql_upper.find(" WHERE ") {
|
||||
let mut where_end = sql_upper.find(" ORDER BY ").unwrap_or(sql.len());
|
||||
let mut where_end = sql_upper.find(" ORDER BY ").unwrap_or(sql_upper.len());
|
||||
if let Some(limit_idx) = sql_upper.find(" LIMIT ") {
|
||||
if limit_idx < where_end {
|
||||
where_end = limit_idx;
|
||||
}
|
||||
}
|
||||
let where_clause = &sql[where_idx + 7..where_end];
|
||||
let and_regex = Regex::new(r"(?i)\s+AND\s+").ok()?;
|
||||
let parts = and_regex.split(where_clause);
|
||||
for part in parts {
|
||||
if let Some(eq_idx) = part.find('=') {
|
||||
let left = part[..eq_idx]
|
||||
.trim()
|
||||
.split('.')
|
||||
.last()
|
||||
.unwrap_or("")
|
||||
.trim_matches('"');
|
||||
let right = part[eq_idx + 1..].trim().trim_matches('\'');
|
||||
conditions.push((left.to_string(), right.to_string()));
|
||||
} else if part.to_uppercase().contains(" IS NULL") {
|
||||
let left = part[..part.to_uppercase().find(" IS NULL").unwrap()]
|
||||
.trim()
|
||||
.split('.')
|
||||
.last()
|
||||
.unwrap_or("")
|
||||
.replace('"', ""); // Remove quotes explicitly
|
||||
conditions.push((left, "null".to_string()));
|
||||
}
|
||||
}
|
||||
where_clause = sql[where_idx + 7..where_end].to_string();
|
||||
}
|
||||
|
||||
// 3. Find matching mocks
|
||||
let mut matches = Vec::new();
|
||||
let or_regex = Regex::new(r"(?i)\s+OR\s+").ok()?;
|
||||
let and_regex = Regex::new(r"(?i)\s+AND\s+").ok()?;
|
||||
|
||||
for mock in mocks {
|
||||
if let Some(mock_obj) = mock.as_object() {
|
||||
if let Some(t) = mock_obj.get("type") {
|
||||
@ -168,25 +149,66 @@ fn parse_and_match_mocks(sql: &str, mocks: &[Value]) -> Option<Vec<Value>> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut matches_all = true;
|
||||
for (k, v) in &conditions {
|
||||
let mock_val_str = match mock_obj.get(k) {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::Bool(b)) => b.to_string(),
|
||||
Some(Value::Null) => "null".to_string(),
|
||||
_ => {
|
||||
matches_all = false;
|
||||
break;
|
||||
if where_clause.is_empty() {
|
||||
matches.push(mock.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
let or_parts = or_regex.split(&where_clause);
|
||||
let mut any_branch_matched = false;
|
||||
|
||||
for or_part in or_parts {
|
||||
let branch_str = or_part.replace('(', "").replace(')', "");
|
||||
let mut branch_matches = true;
|
||||
|
||||
for part in and_regex.split(&branch_str) {
|
||||
if let Some(eq_idx) = part.find('=') {
|
||||
let left = part[..eq_idx]
|
||||
.trim()
|
||||
.split('.')
|
||||
.last()
|
||||
.unwrap_or("")
|
||||
.trim_matches('"');
|
||||
let right = part[eq_idx + 1..].trim().trim_matches('\'');
|
||||
|
||||
let mock_val_str = match mock_obj.get(left) {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::Bool(b)) => b.to_string(),
|
||||
Some(Value::Null) => "null".to_string(),
|
||||
_ => "".to_string(),
|
||||
};
|
||||
if mock_val_str != right {
|
||||
branch_matches = false;
|
||||
break;
|
||||
}
|
||||
} else if part.to_uppercase().contains(" IS NULL") {
|
||||
let left = part[..part.to_uppercase().find(" IS NULL").unwrap()]
|
||||
.trim()
|
||||
.split('.')
|
||||
.last()
|
||||
.unwrap_or("")
|
||||
.trim_matches('"');
|
||||
|
||||
let mock_val_str = match mock_obj.get(left) {
|
||||
Some(Value::Null) => "null".to_string(),
|
||||
_ => "".to_string(),
|
||||
};
|
||||
|
||||
if mock_val_str != "null" {
|
||||
branch_matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
if mock_val_str != *v {
|
||||
matches_all = false;
|
||||
}
|
||||
|
||||
if branch_matches {
|
||||
any_branch_matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if matches_all {
|
||||
if any_branch_matched {
|
||||
matches.push(mock.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,61 @@ impl SpiExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
fn transact<F, R>(&self, f: F) -> Result<R, String>
|
||||
where
|
||||
F: FnOnce() -> Result<R, String>,
|
||||
{
|
||||
unsafe {
|
||||
let oldcontext = pgrx::pg_sys::CurrentMemoryContext;
|
||||
let oldowner = pgrx::pg_sys::CurrentResourceOwner;
|
||||
pgrx::pg_sys::BeginInternalSubTransaction(std::ptr::null());
|
||||
pgrx::pg_sys::MemoryContextSwitchTo(oldcontext);
|
||||
|
||||
let runner = std::panic::AssertUnwindSafe(move || {
|
||||
let res = f();
|
||||
|
||||
pgrx::pg_sys::ReleaseCurrentSubTransaction();
|
||||
pgrx::pg_sys::MemoryContextSwitchTo(oldcontext);
|
||||
pgrx::pg_sys::CurrentResourceOwner = oldowner;
|
||||
|
||||
res
|
||||
});
|
||||
|
||||
pgrx::PgTryBuilder::new(runner)
|
||||
.catch_rust_panic(|cause| {
|
||||
pgrx::pg_sys::RollbackAndReleaseCurrentSubTransaction();
|
||||
pgrx::pg_sys::MemoryContextSwitchTo(oldcontext);
|
||||
pgrx::pg_sys::CurrentResourceOwner = oldowner;
|
||||
|
||||
// Rust panics are fatal bugs, not validation errors. Rethrow so they bubble up.
|
||||
cause.rethrow()
|
||||
})
|
||||
.catch_others(|cause| {
|
||||
pgrx::pg_sys::RollbackAndReleaseCurrentSubTransaction();
|
||||
pgrx::pg_sys::MemoryContextSwitchTo(oldcontext);
|
||||
pgrx::pg_sys::CurrentResourceOwner = oldowner;
|
||||
|
||||
let error_msg = match &cause {
|
||||
pgrx::pg_sys::panic::CaughtError::PostgresError(e)
|
||||
| pgrx::pg_sys::panic::CaughtError::ErrorReport(e) => {
|
||||
let json_err = serde_json::json!({
|
||||
"error": e.message(),
|
||||
"code": format!("{:?}", e.sql_error_code()),
|
||||
"detail": e.detail(),
|
||||
"hint": e.hint()
|
||||
});
|
||||
json_err.to_string()
|
||||
}
|
||||
_ => format!("{:?}", cause),
|
||||
};
|
||||
|
||||
pgrx::warning!("JSPG Caught Native Postgres Error: {}", error_msg);
|
||||
Err(error_msg)
|
||||
})
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseExecutor for SpiExecutor {
|
||||
@ -24,7 +79,7 @@ impl DatabaseExecutor for SpiExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
pgrx::PgTryBuilder::new(|| {
|
||||
self.transact(|| {
|
||||
Spi::connect(|client| {
|
||||
pgrx::notice!("JSPG_SQL: {}", sql);
|
||||
match client.select(sql, Some(args_with_oid.len() as i64), &args_with_oid) {
|
||||
@ -41,11 +96,6 @@ impl DatabaseExecutor for SpiExecutor {
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch_others(|cause| {
|
||||
pgrx::warning!("JSPG Caught Native Postgres Error: {:?}", cause);
|
||||
Err(format!("{:?}", cause))
|
||||
})
|
||||
.execute()
|
||||
}
|
||||
|
||||
fn execute(&self, sql: &str, args: Option<&[Value]>) -> Result<(), String> {
|
||||
@ -60,7 +110,7 @@ impl DatabaseExecutor for SpiExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
pgrx::PgTryBuilder::new(|| {
|
||||
self.transact(|| {
|
||||
Spi::connect_mut(|client| {
|
||||
pgrx::notice!("JSPG_SQL: {}", sql);
|
||||
match client.update(sql, Some(args_with_oid.len() as i64), &args_with_oid) {
|
||||
@ -69,44 +119,43 @@ impl DatabaseExecutor for SpiExecutor {
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch_others(|cause| {
|
||||
pgrx::warning!("JSPG Caught Native Postgres Error: {:?}", cause);
|
||||
Err(format!("{:?}", cause))
|
||||
})
|
||||
.execute()
|
||||
}
|
||||
|
||||
fn auth_user_id(&self) -> Result<String, String> {
|
||||
Spi::connect(|client| {
|
||||
let mut tup_table = client
|
||||
.select(
|
||||
"SELECT COALESCE(current_setting('auth.user_id', true), 'ffffffff-ffff-ffff-ffff-ffffffffffff')",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| format!("SPI Select Error: {}", e))?;
|
||||
self.transact(|| {
|
||||
Spi::connect(|client| {
|
||||
let mut tup_table = client
|
||||
.select(
|
||||
"SELECT COALESCE(current_setting('auth.user_id', true), 'ffffffff-ffff-ffff-ffff-ffffffffffff')",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| format!("SPI Select Error: {}", e))?;
|
||||
|
||||
let row = tup_table
|
||||
.next()
|
||||
.ok_or("No user id setting returned from context".to_string())?;
|
||||
let user_id: Option<String> = row.get(1).map_err(|e| e.to_string())?;
|
||||
let row = tup_table
|
||||
.next()
|
||||
.ok_or("No user id setting returned from context".to_string())?;
|
||||
let user_id: Option<String> = row.get(1).map_err(|e| e.to_string())?;
|
||||
|
||||
user_id.ok_or("Missing user_id".to_string())
|
||||
user_id.ok_or("Missing user_id".to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn timestamp(&self) -> Result<String, String> {
|
||||
Spi::connect(|client| {
|
||||
let mut tup_table = client
|
||||
.select("SELECT clock_timestamp()::text", None, &[])
|
||||
.map_err(|e| format!("SPI Select Error: {}", e))?;
|
||||
self.transact(|| {
|
||||
Spi::connect(|client| {
|
||||
let mut tup_table = client
|
||||
.select("SELECT clock_timestamp()::text", None, &[])
|
||||
.map_err(|e| format!("SPI Select Error: {}", e))?;
|
||||
|
||||
let row = tup_table
|
||||
.next()
|
||||
.ok_or("No clock timestamp returned".to_string())?;
|
||||
let timestamp: Option<String> = row.get(1).map_err(|e| e.to_string())?;
|
||||
let row = tup_table
|
||||
.next()
|
||||
.ok_or("No clock timestamp returned".to_string())?;
|
||||
let timestamp: Option<String> = row.get(1).map_err(|e| e.to_string())?;
|
||||
|
||||
timestamp.ok_or("Missing timestamp".to_string())
|
||||
timestamp.ok_or("Missing timestamp".to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod edge;
|
||||
pub mod r#enum;
|
||||
pub mod executors;
|
||||
pub mod formats;
|
||||
@ -18,24 +19,19 @@ use executors::pgrx::SpiExecutor;
|
||||
#[cfg(test)]
|
||||
use executors::mock::MockExecutor;
|
||||
|
||||
pub mod stem;
|
||||
use punc::Punc;
|
||||
use relation::Relation;
|
||||
use schema::Schema;
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use stem::Stem;
|
||||
use r#type::Type;
|
||||
|
||||
pub struct Database {
|
||||
pub enums: HashMap<String, Enum>,
|
||||
pub types: HashMap<String, Type>,
|
||||
pub puncs: HashMap<String, Punc>,
|
||||
pub relations: HashMap<(String, String), Vec<Relation>>,
|
||||
pub relations: HashMap<String, Relation>,
|
||||
pub schemas: HashMap<String, Schema>,
|
||||
// Map of Schema ID -> { Entity Type -> Target Subschema Arc }
|
||||
pub stems: HashMap<String, HashMap<String, Arc<Stem>>>,
|
||||
pub descendants: HashMap<String, Vec<String>>,
|
||||
pub depths: HashMap<String, usize>,
|
||||
pub executor: Box<dyn DatabaseExecutor + Send + Sync>,
|
||||
@ -49,7 +45,6 @@ impl Database {
|
||||
relations: HashMap::new(),
|
||||
puncs: HashMap::new(),
|
||||
schemas: HashMap::new(),
|
||||
stems: HashMap::new(),
|
||||
descendants: HashMap::new(),
|
||||
depths: HashMap::new(),
|
||||
#[cfg(not(test))]
|
||||
@ -74,14 +69,28 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
let mut raw_relations = Vec::new();
|
||||
if let Some(arr) = val.get("relations").and_then(|v| v.as_array()) {
|
||||
for item in arr {
|
||||
match serde_json::from_value::<Relation>(item.clone()) {
|
||||
Ok(def) => {
|
||||
raw_relations.push(def);
|
||||
if db.types.contains_key(&def.source_type)
|
||||
&& db.types.contains_key(&def.destination_type)
|
||||
{
|
||||
db.relations.insert(def.constraint.clone(), def);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||
code: "DATABASE_RELATION_PARSE_FAILED".to_string(),
|
||||
message: format!("Failed to parse database relation: {}", e),
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
cause: None,
|
||||
context: None,
|
||||
schema: None,
|
||||
},
|
||||
}]));
|
||||
}
|
||||
Err(e) => println!("DATABASE RELATION PARSE FAILED: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -108,7 +117,7 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
db.compile(raw_relations)?;
|
||||
db.compile()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@ -138,41 +147,68 @@ impl Database {
|
||||
self.executor.timestamp()
|
||||
}
|
||||
|
||||
/// Organizes the graph of the database, compiling regex, format functions, and caching relationships.
|
||||
pub fn compile(&mut self, raw_relations: Vec<Relation>) -> Result<(), crate::drop::Drop> {
|
||||
self.collect_schemas();
|
||||
pub fn compile(&mut self) -> Result<(), crate::drop::Drop> {
|
||||
let mut harvested = Vec::new();
|
||||
for schema in self.schemas.values_mut() {
|
||||
if let Err(msg) = schema.collect_schemas(None, &mut harvested) {
|
||||
return Err(crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||
code: "SCHEMA_VALIDATION_FAILED".to_string(),
|
||||
message: msg,
|
||||
details: crate::drop::ErrorDetails { path: "".to_string(), cause: None, context: None, schema: None },
|
||||
}]));
|
||||
}
|
||||
}
|
||||
self.schemas.extend(harvested);
|
||||
|
||||
if let Err(msg) = self.collect_schemas() {
|
||||
return Err(crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||
code: "SCHEMA_VALIDATION_FAILED".to_string(),
|
||||
message: msg,
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
cause: None,
|
||||
context: None,
|
||||
schema: None,
|
||||
},
|
||||
}]));
|
||||
}
|
||||
self.collect_depths();
|
||||
self.collect_descendants();
|
||||
self.collect_relations(raw_relations);
|
||||
self.compile_schemas();
|
||||
self.collect_stems()?;
|
||||
|
||||
// Mathematically evaluate all property inheritances, formats, schemas, and foreign key edges topographically over OnceLocks
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
for schema in self.schemas.values() {
|
||||
schema.compile(self, &mut visited);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_schemas(&mut self) {
|
||||
fn collect_schemas(&mut self) -> Result<(), String> {
|
||||
let mut to_insert = Vec::new();
|
||||
|
||||
// Pass 1: Extract all Schemas structurally off top level definitions into the master registry.
|
||||
// Validate every node recursively via string filters natively!
|
||||
for type_def in self.types.values() {
|
||||
for mut schema in type_def.schemas.clone() {
|
||||
schema.harvest(&mut to_insert);
|
||||
schema.collect_schemas(None, &mut to_insert)?;
|
||||
}
|
||||
}
|
||||
for punc_def in self.puncs.values() {
|
||||
for mut schema in punc_def.schemas.clone() {
|
||||
schema.harvest(&mut to_insert);
|
||||
schema.collect_schemas(None, &mut to_insert)?;
|
||||
}
|
||||
}
|
||||
for enum_def in self.enums.values() {
|
||||
for mut schema in enum_def.schemas.clone() {
|
||||
schema.harvest(&mut to_insert);
|
||||
schema.collect_schemas(None, &mut to_insert)?;
|
||||
}
|
||||
}
|
||||
|
||||
for (id, schema) in to_insert {
|
||||
self.schemas.insert(id, schema);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_depths(&mut self) {
|
||||
@ -228,99 +264,10 @@ impl Database {
|
||||
self.descendants = descendants;
|
||||
}
|
||||
|
||||
fn collect_relations(&mut self, raw_relations: Vec<Relation>) {
|
||||
let mut edges: HashMap<(String, String), Vec<Relation>> = HashMap::new();
|
||||
|
||||
// For every relation, map it across all polymorphic inheritance permutations
|
||||
for relation in raw_relations {
|
||||
if let Some(_source_type_def) = self.types.get(&relation.source_type) {
|
||||
if let Some(_dest_type_def) = self.types.get(&relation.destination_type) {
|
||||
let mut src_descendants = Vec::new();
|
||||
let mut dest_descendants = Vec::new();
|
||||
|
||||
for (t_name, t_def) in &self.types {
|
||||
if t_def.hierarchy.contains(&relation.source_type) {
|
||||
src_descendants.push(t_name.clone());
|
||||
}
|
||||
if t_def.hierarchy.contains(&relation.destination_type) {
|
||||
dest_descendants.push(t_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for p_type in &src_descendants {
|
||||
for c_type in &dest_descendants {
|
||||
// Ignore entity <-> entity generic fallbacks, they aren't useful edges
|
||||
if p_type == "entity" && c_type == "entity" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Forward edge
|
||||
edges
|
||||
.entry((p_type.clone(), c_type.clone()))
|
||||
.or_default()
|
||||
.push(relation.clone());
|
||||
|
||||
// Reverse edge (only if types are different to avoid duplicating self-referential edges like activity parent_id)
|
||||
if p_type != c_type {
|
||||
edges
|
||||
.entry((c_type.clone(), p_type.clone()))
|
||||
.or_default()
|
||||
.push(relation.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.relations = edges;
|
||||
}
|
||||
|
||||
pub fn get_relation(
|
||||
&self,
|
||||
parent_type: &str,
|
||||
child_type: &str,
|
||||
prop_name: &str,
|
||||
relative_keys: Option<&Vec<String>>,
|
||||
) -> Option<&Relation> {
|
||||
if let Some(relations) = self
|
||||
.relations
|
||||
.get(&(parent_type.to_string(), child_type.to_string()))
|
||||
{
|
||||
if relations.len() == 1 {
|
||||
return Some(&relations[0]);
|
||||
}
|
||||
|
||||
// Reduce ambiguity with prefix
|
||||
for rel in relations {
|
||||
if let Some(prefix) = &rel.prefix {
|
||||
if prefix == prop_name {
|
||||
return Some(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce ambiguity by checking if relative payload OMITS the prefix (M:M heuristic)
|
||||
if let Some(keys) = relative_keys {
|
||||
let mut missing_prefix_rels = Vec::new();
|
||||
for rel in relations {
|
||||
if let Some(prefix) = &rel.prefix {
|
||||
if !keys.contains(prefix) {
|
||||
missing_prefix_rels.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
if missing_prefix_rels.len() == 1 {
|
||||
return Some(missing_prefix_rels[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn collect_descendants_recursively(
|
||||
target: &str,
|
||||
direct_refs: &HashMap<String, Vec<String>>,
|
||||
descendants: &mut HashSet<String>,
|
||||
direct_refs: &std::collections::HashMap<String, Vec<String>>,
|
||||
descendants: &mut std::collections::HashSet<String>,
|
||||
) {
|
||||
if let Some(children) = direct_refs.get(target) {
|
||||
for child in children {
|
||||
@ -330,246 +277,4 @@ impl Database {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_schemas(&mut self) {
|
||||
// Pass 3: compile_internals across pure structure
|
||||
let schema_ids: Vec<String> = self.schemas.keys().cloned().collect();
|
||||
for id in schema_ids {
|
||||
if let Some(schema) = self.schemas.get_mut(&id) {
|
||||
schema.compile_internals();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_stems(&mut self) -> Result<(), crate::drop::Drop> {
|
||||
let mut db_stems: HashMap<String, HashMap<String, Arc<Stem>>> = HashMap::new();
|
||||
let mut errors: Vec<crate::drop::Error> = Vec::new();
|
||||
|
||||
let schema_ids: Vec<String> = self.schemas.keys().cloned().collect();
|
||||
for schema_id in schema_ids {
|
||||
if let Some(schema) = self.schemas.get(&schema_id) {
|
||||
let mut inner_map = HashMap::new();
|
||||
Self::discover_stems(
|
||||
self,
|
||||
&schema_id,
|
||||
schema,
|
||||
String::from(""),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
&mut inner_map,
|
||||
Vec::new(),
|
||||
&mut errors,
|
||||
);
|
||||
if !inner_map.is_empty() {
|
||||
db_stems.insert(schema_id, inner_map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.stems = db_stems;
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(crate::drop::Drop::with_errors(errors));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn discover_stems(
|
||||
db: &Database,
|
||||
root_schema_id: &str,
|
||||
schema: &Schema,
|
||||
current_path: String,
|
||||
parent_type: Option<String>,
|
||||
property_name: Option<String>,
|
||||
is_polymorphic: bool,
|
||||
inner_map: &mut HashMap<String, Arc<Stem>>,
|
||||
seen_entities: Vec<String>,
|
||||
errors: &mut Vec<crate::drop::Error>,
|
||||
) {
|
||||
let mut is_entity = false;
|
||||
let mut entity_type = String::new();
|
||||
|
||||
// First check if the Schema's $id is a native Database Type
|
||||
if let Some(ref id) = schema.obj.id {
|
||||
let parts: Vec<&str> = id.split('.').collect();
|
||||
if let Some(last_seg) = parts.last() {
|
||||
if db.types.contains_key(*last_seg) {
|
||||
is_entity = true;
|
||||
entity_type = last_seg.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not found via $id, check the $ref pointer
|
||||
// This allows ad-hoc schemas (like `save_person.response`) to successfully adopt the Type of what they $ref
|
||||
if !is_entity {
|
||||
if let Some(ref r) = schema.obj.r#ref {
|
||||
let parts: Vec<&str> = r.split('.').collect();
|
||||
if let Some(last_seg) = parts.last() {
|
||||
if db.types.contains_key(*last_seg) {
|
||||
is_entity = true;
|
||||
entity_type = last_seg.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_entity {
|
||||
if seen_entities.contains(&entity_type) {
|
||||
return; // Break cyclical schemas!
|
||||
}
|
||||
}
|
||||
|
||||
let mut relation_col = None;
|
||||
if is_entity {
|
||||
if let (Some(pt), Some(prop)) = (&parent_type, &property_name) {
|
||||
let expected_col = format!("{}_id", prop);
|
||||
let mut found = false;
|
||||
|
||||
if let Some(rel) = db.get_relation(pt, &entity_type, prop, None) {
|
||||
if rel.source_columns.contains(&expected_col) {
|
||||
relation_col = Some(expected_col.clone());
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
relation_col = Some(expected_col);
|
||||
}
|
||||
}
|
||||
|
||||
let mut final_path = current_path.clone();
|
||||
if is_polymorphic && !final_path.is_empty() && !final_path.ends_with(&entity_type) {
|
||||
if final_path.ends_with(".#") {
|
||||
final_path = format!("{}(type==\"{}\")", final_path, entity_type);
|
||||
} else {
|
||||
final_path = format!("{}#(type==\"{}\")", final_path, entity_type);
|
||||
}
|
||||
}
|
||||
|
||||
let stem = Stem {
|
||||
r#type: entity_type.clone(),
|
||||
relation: relation_col,
|
||||
schema: Arc::new(schema.clone()),
|
||||
};
|
||||
inner_map.insert(final_path, Arc::new(stem));
|
||||
}
|
||||
|
||||
let next_parent = if is_entity {
|
||||
Some(entity_type.clone())
|
||||
} else {
|
||||
parent_type.clone()
|
||||
};
|
||||
|
||||
let pass_seen = if is_entity {
|
||||
let mut ns = seen_entities.clone();
|
||||
ns.push(entity_type.clone());
|
||||
ns
|
||||
} else {
|
||||
seen_entities.clone()
|
||||
};
|
||||
|
||||
// Properties branch
|
||||
if let Some(props) = &schema.obj.properties {
|
||||
for (k, v) in props {
|
||||
// Standard Property Pathing
|
||||
let next_path = if current_path.is_empty() {
|
||||
k.clone()
|
||||
} else {
|
||||
format!("{}.{}", current_path, k)
|
||||
};
|
||||
|
||||
Self::discover_stems(
|
||||
db,
|
||||
root_schema_id,
|
||||
v,
|
||||
next_path,
|
||||
next_parent.clone(),
|
||||
Some(k.clone()),
|
||||
false,
|
||||
inner_map,
|
||||
pass_seen.clone(),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Array Item branch
|
||||
if let Some(items) = &schema.obj.items {
|
||||
let next_path = if current_path.is_empty() {
|
||||
String::from("#")
|
||||
} else {
|
||||
format!("{}.#", current_path)
|
||||
};
|
||||
|
||||
Self::discover_stems(
|
||||
db,
|
||||
root_schema_id,
|
||||
items,
|
||||
next_path,
|
||||
next_parent.clone(),
|
||||
property_name.clone(),
|
||||
false,
|
||||
inner_map,
|
||||
pass_seen.clone(),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
|
||||
// Follow external reference if we didn't just crawl local properties
|
||||
if schema.obj.properties.is_none() && schema.obj.items.is_none() && schema.obj.one_of.is_none()
|
||||
{
|
||||
if let Some(ref r) = schema.obj.r#ref {
|
||||
if let Some(target_schema) = db.schemas.get(r) {
|
||||
Self::discover_stems(
|
||||
db,
|
||||
root_schema_id,
|
||||
target_schema,
|
||||
current_path.clone(),
|
||||
next_parent.clone(),
|
||||
property_name.clone(),
|
||||
is_polymorphic,
|
||||
inner_map,
|
||||
seen_entities.clone(),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Polymorphism branch
|
||||
if let Some(arr) = &schema.obj.one_of {
|
||||
for v in arr {
|
||||
Self::discover_stems(
|
||||
db,
|
||||
root_schema_id,
|
||||
v.as_ref(),
|
||||
current_path.clone(),
|
||||
next_parent.clone(),
|
||||
property_name.clone(),
|
||||
true,
|
||||
inner_map,
|
||||
pass_seen.clone(),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(arr) = &schema.obj.all_of {
|
||||
for v in arr {
|
||||
Self::discover_stems(
|
||||
db,
|
||||
root_schema_id,
|
||||
v.as_ref(),
|
||||
current_path.clone(),
|
||||
next_parent.clone(),
|
||||
property_name.clone(),
|
||||
is_polymorphic,
|
||||
inner_map,
|
||||
pass_seen.clone(),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,26 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub fn serialize_once_lock<T: serde::Serialize, S: serde::Serializer>(
|
||||
lock: &OnceLock<T>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
if let Some(val) = lock.get() {
|
||||
val.serialize(serializer)
|
||||
} else {
|
||||
serializer.serialize_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_once_lock_map_empty<K, V>(lock: &OnceLock<std::collections::BTreeMap<K, V>>) -> bool {
|
||||
lock.get().map_or(true, |m| m.is_empty())
|
||||
}
|
||||
|
||||
pub fn is_once_lock_vec_empty<T>(lock: &OnceLock<Vec<T>>) -> bool {
|
||||
lock.get().map_or(true, |v| v.is_empty())
|
||||
}
|
||||
|
||||
// Schema mirrors the Go Punc Generator's schema struct for consistency.
|
||||
// It is an order-preserving representation of a JSON Schema.
|
||||
@ -167,12 +187,27 @@ pub struct SchemaObject {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extensible: Option<bool>,
|
||||
|
||||
#[serde(rename = "compiledProperties")]
|
||||
#[serde(skip_deserializing)]
|
||||
#[serde(skip_serializing_if = "crate::database::schema::is_once_lock_vec_empty")]
|
||||
#[serde(serialize_with = "crate::database::schema::serialize_once_lock")]
|
||||
pub compiled_property_names: OnceLock<Vec<String>>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub compiled_format: Option<CompiledFormat>,
|
||||
pub compiled_properties: OnceLock<BTreeMap<String, Arc<Schema>>>,
|
||||
|
||||
#[serde(rename = "compiledEdges")]
|
||||
#[serde(skip_deserializing)]
|
||||
#[serde(skip_serializing_if = "crate::database::schema::is_once_lock_map_empty")]
|
||||
#[serde(serialize_with = "crate::database::schema::serialize_once_lock")]
|
||||
pub compiled_edges: OnceLock<BTreeMap<String, crate::database::edge::Edge>>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub compiled_pattern: Option<CompiledRegex>,
|
||||
pub compiled_format: OnceLock<CompiledFormat>,
|
||||
#[serde(skip)]
|
||||
pub compiled_pattern_properties: Option<Vec<(CompiledRegex, Arc<Schema>)>>,
|
||||
pub compiled_pattern: OnceLock<CompiledRegex>,
|
||||
#[serde(skip)]
|
||||
pub compiled_pattern_properties: OnceLock<Vec<(CompiledRegex, Arc<Schema>)>>,
|
||||
}
|
||||
|
||||
/// Represents a compiled format validator
|
||||
@ -216,19 +251,37 @@ impl std::ops::DerefMut for Schema {
|
||||
}
|
||||
|
||||
impl Schema {
|
||||
pub fn compile_internals(&mut self) {
|
||||
self.map_children(|child| child.compile_internals());
|
||||
|
||||
if let Some(format_str) = &self.obj.format
|
||||
&& let Some(fmt) = crate::database::formats::FORMATS.get(format_str.as_str())
|
||||
{
|
||||
self.obj.compiled_format = Some(crate::database::schema::CompiledFormat::Func(fmt.func));
|
||||
pub fn compile(
|
||||
&self,
|
||||
db: &crate::database::Database,
|
||||
visited: &mut std::collections::HashSet<String>,
|
||||
) {
|
||||
if self.obj.compiled_properties.get().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(pattern_str) = &self.obj.pattern
|
||||
&& let Ok(re) = regex::Regex::new(pattern_str)
|
||||
{
|
||||
self.obj.compiled_pattern = Some(crate::database::schema::CompiledRegex(re));
|
||||
if let Some(id) = &self.obj.id {
|
||||
if !visited.insert(id.clone()) {
|
||||
return; // Break cyclical resolution
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(format_str) = &self.obj.format {
|
||||
if let Some(fmt) = crate::database::formats::FORMATS.get(format_str.as_str()) {
|
||||
let _ = self
|
||||
.obj
|
||||
.compiled_format
|
||||
.set(crate::database::schema::CompiledFormat::Func(fmt.func));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pattern_str) = &self.obj.pattern {
|
||||
if let Ok(re) = regex::Regex::new(pattern_str) {
|
||||
let _ = self
|
||||
.obj
|
||||
.compiled_pattern
|
||||
.set(crate::database::schema::CompiledRegex(re));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pattern_props) = &self.obj.pattern_properties {
|
||||
@ -239,73 +292,352 @@ impl Schema {
|
||||
}
|
||||
}
|
||||
if !compiled.is_empty() {
|
||||
self.obj.compiled_pattern_properties = Some(compiled);
|
||||
let _ = self.obj.compiled_pattern_properties.set(compiled);
|
||||
}
|
||||
}
|
||||
|
||||
let mut props = std::collections::BTreeMap::new();
|
||||
|
||||
// 1. Resolve INHERITANCE dependencies first
|
||||
if let Some(ref_id) = &self.obj.r#ref {
|
||||
if let Some(parent) = db.schemas.get(ref_id) {
|
||||
parent.compile(db, visited);
|
||||
if let Some(p_props) = parent.obj.compiled_properties.get() {
|
||||
props.extend(p_props.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(all_of) = &self.obj.all_of {
|
||||
for ao in all_of {
|
||||
ao.compile(db, visited);
|
||||
if let Some(ao_props) = ao.obj.compiled_properties.get() {
|
||||
props.extend(ao_props.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(then_schema) = &self.obj.then_ {
|
||||
then_schema.compile(db, visited);
|
||||
if let Some(t_props) = then_schema.obj.compiled_properties.get() {
|
||||
props.extend(t_props.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(else_schema) = &self.obj.else_ {
|
||||
else_schema.compile(db, visited);
|
||||
if let Some(e_props) = else_schema.obj.compiled_properties.get() {
|
||||
props.extend(e_props.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add local properties
|
||||
if let Some(local_props) = &self.obj.properties {
|
||||
for (k, v) in local_props {
|
||||
props.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Set the OnceLock!
|
||||
let _ = self.obj.compiled_properties.set(props.clone());
|
||||
let mut names: Vec<String> = props.keys().cloned().collect();
|
||||
names.sort();
|
||||
let _ = self.obj.compiled_property_names.set(names);
|
||||
|
||||
// 4. Compute Edges natively
|
||||
let schema_edges = self.compile_edges(db, visited, &props);
|
||||
let _ = self.obj.compiled_edges.set(schema_edges);
|
||||
|
||||
// 5. Build our inline children properties recursively NOW! (Depth-first search)
|
||||
if let Some(local_props) = &self.obj.properties {
|
||||
for child in local_props.values() {
|
||||
child.compile(db, visited);
|
||||
}
|
||||
}
|
||||
if let Some(items) = &self.obj.items {
|
||||
items.compile(db, visited);
|
||||
}
|
||||
if let Some(pattern_props) = &self.obj.pattern_properties {
|
||||
for child in pattern_props.values() {
|
||||
child.compile(db, visited);
|
||||
}
|
||||
}
|
||||
if let Some(additional_props) = &self.obj.additional_properties {
|
||||
additional_props.compile(db, visited);
|
||||
}
|
||||
if let Some(one_of) = &self.obj.one_of {
|
||||
for child in one_of {
|
||||
child.compile(db, visited);
|
||||
}
|
||||
}
|
||||
if let Some(arr) = &self.obj.prefix_items {
|
||||
for child in arr {
|
||||
child.compile(db, visited);
|
||||
}
|
||||
}
|
||||
if let Some(child) = &self.obj.not {
|
||||
child.compile(db, visited);
|
||||
}
|
||||
if let Some(child) = &self.obj.contains {
|
||||
child.compile(db, visited);
|
||||
}
|
||||
if let Some(child) = &self.obj.property_names {
|
||||
child.compile(db, visited);
|
||||
}
|
||||
if let Some(child) = &self.obj.if_ {
|
||||
child.compile(db, visited);
|
||||
}
|
||||
|
||||
if let Some(id) = &self.obj.id {
|
||||
visited.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn harvest(&mut self, to_insert: &mut Vec<(String, Schema)>) {
|
||||
#[allow(unused_variables)]
|
||||
fn validate_identifier(id: &str, field_name: &str) -> Result<(), String> {
|
||||
#[cfg(not(test))]
|
||||
for c in id.chars() {
|
||||
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' && c != '.' {
|
||||
return Err(format!("Invalid character '{}' in JSON Schema '{}' property: '{}'. Identifiers must exclusively contain [a-z0-9_.]", c, field_name, id));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn collect_schemas(
|
||||
&mut self,
|
||||
tracking_path: Option<String>,
|
||||
to_insert: &mut Vec<(String, Schema)>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(id) = &self.obj.id {
|
||||
Self::validate_identifier(id, "$id")?;
|
||||
to_insert.push((id.clone(), self.clone()));
|
||||
}
|
||||
self.map_children(|child| child.harvest(to_insert));
|
||||
if let Some(r#ref) = &self.obj.r#ref {
|
||||
Self::validate_identifier(r#ref, "$ref")?;
|
||||
}
|
||||
if let Some(family) = &self.obj.family {
|
||||
Self::validate_identifier(family, "$family")?;
|
||||
}
|
||||
|
||||
// Is this schema an inline ad-hoc composition?
|
||||
// Meaning it has a tracking context, lacks an explicit $id, but extends an Entity ref with explicit properties!
|
||||
if self.obj.id.is_none() && self.obj.r#ref.is_some() && self.obj.properties.is_some() {
|
||||
if let Some(ref path) = tracking_path {
|
||||
to_insert.push((path.clone(), self.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Provide the path origin to children natively, prioritizing the explicit `$id` boundary if one exists
|
||||
let origin_path = self.obj.id.clone().or(tracking_path);
|
||||
|
||||
self.collect_child_schemas(origin_path, to_insert)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn map_children<F>(&mut self, mut f: F)
|
||||
where
|
||||
F: FnMut(&mut Schema),
|
||||
{
|
||||
pub fn collect_child_schemas(
|
||||
&mut self,
|
||||
origin_path: Option<String>,
|
||||
to_insert: &mut Vec<(String, Schema)>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(props) = &mut self.obj.properties {
|
||||
for v in props.values_mut() {
|
||||
for (k, v) in props.iter_mut() {
|
||||
let mut inner = (**v).clone();
|
||||
f(&mut inner);
|
||||
let next_path = origin_path.as_ref().map(|o| format!("{}/{}", o, k));
|
||||
inner.collect_schemas(next_path, to_insert)?;
|
||||
*v = Arc::new(inner);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pattern_props) = &mut self.obj.pattern_properties {
|
||||
for v in pattern_props.values_mut() {
|
||||
for (k, v) in pattern_props.iter_mut() {
|
||||
let mut inner = (**v).clone();
|
||||
f(&mut inner);
|
||||
let next_path = origin_path.as_ref().map(|o| format!("{}/{}", o, k));
|
||||
inner.collect_schemas(next_path, to_insert)?;
|
||||
*v = Arc::new(inner);
|
||||
}
|
||||
}
|
||||
|
||||
let mut map_arr = |arr: &mut Vec<Arc<Schema>>| {
|
||||
let mut map_arr = |arr: &mut Vec<Arc<Schema>>| -> Result<(), String> {
|
||||
for v in arr.iter_mut() {
|
||||
let mut inner = (**v).clone();
|
||||
f(&mut inner);
|
||||
inner.collect_schemas(origin_path.clone(), to_insert)?;
|
||||
*v = Arc::new(inner);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if let Some(arr) = &mut self.obj.prefix_items {
|
||||
map_arr(arr);
|
||||
}
|
||||
if let Some(arr) = &mut self.obj.all_of {
|
||||
map_arr(arr);
|
||||
}
|
||||
if let Some(arr) = &mut self.obj.one_of {
|
||||
map_arr(arr);
|
||||
}
|
||||
if let Some(arr) = &mut self.obj.prefix_items { map_arr(arr)?; }
|
||||
if let Some(arr) = &mut self.obj.all_of { map_arr(arr)?; }
|
||||
if let Some(arr) = &mut self.obj.one_of { map_arr(arr)?; }
|
||||
|
||||
let mut map_opt = |opt: &mut Option<Arc<Schema>>| {
|
||||
let mut map_opt = |opt: &mut Option<Arc<Schema>>, pass_path: bool| -> Result<(), String> {
|
||||
if let Some(v) = opt {
|
||||
let mut inner = (**v).clone();
|
||||
f(&mut inner);
|
||||
let next = if pass_path { origin_path.clone() } else { None };
|
||||
inner.collect_schemas(next, to_insert)?;
|
||||
*v = Arc::new(inner);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
map_opt(&mut self.obj.additional_properties);
|
||||
map_opt(&mut self.obj.items);
|
||||
map_opt(&mut self.obj.contains);
|
||||
map_opt(&mut self.obj.property_names);
|
||||
map_opt(&mut self.obj.not);
|
||||
map_opt(&mut self.obj.if_);
|
||||
map_opt(&mut self.obj.then_);
|
||||
map_opt(&mut self.obj.else_);
|
||||
map_opt(&mut self.obj.additional_properties, false)?;
|
||||
|
||||
// `items` absolutely must inherit the EXACT property path assigned to the Array wrapper!
|
||||
// This allows nested Arrays enclosing bare Entity structs to correctly register as the boundary mapping.
|
||||
map_opt(&mut self.obj.items, true)?;
|
||||
|
||||
map_opt(&mut self.obj.not, false)?;
|
||||
map_opt(&mut self.obj.contains, false)?;
|
||||
map_opt(&mut self.obj.property_names, false)?;
|
||||
map_opt(&mut self.obj.if_, false)?;
|
||||
map_opt(&mut self.obj.then_, false)?;
|
||||
map_opt(&mut self.obj.else_, false)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compile_edges(
|
||||
&self,
|
||||
db: &crate::database::Database,
|
||||
visited: &mut std::collections::HashSet<String>,
|
||||
props: &std::collections::BTreeMap<String, std::sync::Arc<Schema>>,
|
||||
) -> std::collections::BTreeMap<String, crate::database::edge::Edge> {
|
||||
let mut schema_edges = std::collections::BTreeMap::new();
|
||||
let mut parent_type_name = None;
|
||||
if let Some(family) = &self.obj.family {
|
||||
parent_type_name = Some(family.split('.').next_back().unwrap_or(family).to_string());
|
||||
} else if let Some(identifier) = self.obj.identifier() {
|
||||
parent_type_name = Some(identifier);
|
||||
}
|
||||
|
||||
if let Some(p_type) = parent_type_name {
|
||||
if db.types.contains_key(&p_type) {
|
||||
for (prop_name, prop_schema) in props {
|
||||
let mut child_type_name = None;
|
||||
let mut target_schema = prop_schema.clone();
|
||||
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) =
|
||||
&prop_schema.obj.type_
|
||||
{
|
||||
if t == "array" {
|
||||
if let Some(items) = &prop_schema.obj.items {
|
||||
target_schema = items.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(family) = &target_schema.obj.family {
|
||||
child_type_name = Some(family.split('.').next_back().unwrap_or(family).to_string());
|
||||
} else if let Some(ref_id) = target_schema.obj.identifier() {
|
||||
child_type_name = Some(ref_id);
|
||||
} else if let Some(arr) = &target_schema.obj.one_of {
|
||||
if let Some(first) = arr.first() {
|
||||
if let Some(ref_id) = first.obj.identifier() {
|
||||
child_type_name = Some(ref_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(c_type) = child_type_name {
|
||||
if db.types.contains_key(&c_type) {
|
||||
target_schema.compile(db, visited);
|
||||
if let Some(compiled_target_props) = target_schema.obj.compiled_properties.get() {
|
||||
let keys_for_ambiguity: Vec<String> =
|
||||
compiled_target_props.keys().cloned().collect();
|
||||
if let Some((relation, is_forward)) =
|
||||
resolve_relation(db, &p_type, &c_type, prop_name, Some(&keys_for_ambiguity))
|
||||
{
|
||||
schema_edges.insert(
|
||||
prop_name.clone(),
|
||||
crate::database::edge::Edge {
|
||||
constraint: relation.constraint.clone(),
|
||||
forward: is_forward,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
schema_edges
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_relation<'a>(
|
||||
db: &'a crate::database::Database,
|
||||
parent_type: &str,
|
||||
child_type: &str,
|
||||
prop_name: &str,
|
||||
relative_keys: Option<&Vec<String>>,
|
||||
) -> Option<(&'a crate::database::relation::Relation, bool)> {
|
||||
if parent_type == "entity" && child_type == "entity" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let p_def = db.types.get(parent_type)?;
|
||||
let c_def = db.types.get(child_type)?;
|
||||
|
||||
let mut matching_rels = Vec::new();
|
||||
let mut directions = Vec::new();
|
||||
|
||||
for rel in db.relations.values() {
|
||||
let is_forward = p_def.hierarchy.contains(&rel.source_type)
|
||||
&& c_def.hierarchy.contains(&rel.destination_type);
|
||||
let is_reverse = p_def.hierarchy.contains(&rel.destination_type)
|
||||
&& c_def.hierarchy.contains(&rel.source_type);
|
||||
|
||||
if is_forward {
|
||||
matching_rels.push(rel);
|
||||
directions.push(true);
|
||||
} else if is_reverse {
|
||||
matching_rels.push(rel);
|
||||
directions.push(false);
|
||||
}
|
||||
}
|
||||
|
||||
if matching_rels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if matching_rels.len() == 1 {
|
||||
return Some((matching_rels[0], directions[0]));
|
||||
}
|
||||
|
||||
let mut chosen_idx = 0;
|
||||
let mut resolved = false;
|
||||
|
||||
for (i, rel) in matching_rels.iter().enumerate() {
|
||||
if let Some(prefix) = &rel.prefix {
|
||||
if prop_name.starts_with(prefix)
|
||||
|| prefix.starts_with(prop_name)
|
||||
|| prefix.replace("_", "") == prop_name.replace("_", "")
|
||||
{
|
||||
chosen_idx = i;
|
||||
resolved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !resolved && relative_keys.is_some() {
|
||||
let keys = relative_keys.unwrap();
|
||||
let mut missing_prefix_ids = Vec::new();
|
||||
for (i, rel) in matching_rels.iter().enumerate() {
|
||||
if let Some(prefix) = &rel.prefix {
|
||||
if !keys.contains(prefix) {
|
||||
missing_prefix_ids.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if missing_prefix_ids.len() == 1 {
|
||||
chosen_idx = missing_prefix_ids[0];
|
||||
}
|
||||
}
|
||||
|
||||
Some((matching_rels[chosen_idx], directions[chosen_idx]))
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Schema {
|
||||
@ -363,6 +695,16 @@ impl<'de> Deserialize<'de> for Schema {
|
||||
}
|
||||
}
|
||||
|
||||
impl SchemaObject {
|
||||
pub fn identifier(&self) -> Option<String> {
|
||||
if let Some(lookup_key) = self.id.as_ref().or(self.r#ref.as_ref()) {
|
||||
Some(lookup_key.split('.').next_back().unwrap_or("").to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum SchemaTypeOrArray {
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
use crate::database::schema::Schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Stem {
|
||||
pub r#type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub relation: Option<String>,
|
||||
|
||||
pub schema: Arc<Schema>,
|
||||
}
|
||||
@ -15,6 +15,8 @@ pub struct Type {
|
||||
#[serde(default)]
|
||||
pub historical: bool,
|
||||
#[serde(default)]
|
||||
pub notify: bool,
|
||||
#[serde(default)]
|
||||
pub sensitive: bool,
|
||||
#[serde(default)]
|
||||
pub ownable: bool,
|
||||
|
||||
@ -70,7 +70,7 @@ pub struct ErrorDetails {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cause: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<Vec<String>>,
|
||||
pub context: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
26
src/lib.rs
26
src/lib.rs
@ -60,7 +60,7 @@ pub fn jspg_setup(database: JsonB) -> JsonB {
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), pg_extern)]
|
||||
pub fn jspg_merge(data: JsonB) -> JsonB {
|
||||
pub fn jspg_merge(schema_id: &str, data: JsonB) -> JsonB {
|
||||
// Try to acquire a read lock to get a clone of the Engine Arc
|
||||
let engine_opt = {
|
||||
let lock = GLOBAL_JSPG.read().unwrap();
|
||||
@ -69,7 +69,7 @@ pub fn jspg_merge(data: JsonB) -> JsonB {
|
||||
|
||||
match engine_opt {
|
||||
Some(engine) => {
|
||||
let drop = engine.merger.merge(data.0);
|
||||
let drop = engine.merger.merge(schema_id, data.0);
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
}
|
||||
None => jspg_failure(),
|
||||
@ -77,7 +77,7 @@ pub fn jspg_merge(data: JsonB) -> JsonB {
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), pg_extern)]
|
||||
pub fn jspg_query(schema_id: &str, stem: Option<&str>, filters: Option<JsonB>) -> JsonB {
|
||||
pub fn jspg_query(schema_id: &str, filters: Option<JsonB>) -> JsonB {
|
||||
let engine_opt = {
|
||||
let lock = GLOBAL_JSPG.read().unwrap();
|
||||
lock.clone()
|
||||
@ -87,7 +87,7 @@ pub fn jspg_query(schema_id: &str, stem: Option<&str>, filters: Option<JsonB>) -
|
||||
Some(engine) => {
|
||||
let drop = engine
|
||||
.queryer
|
||||
.query(schema_id, stem, filters.as_ref().map(|f| &f.0));
|
||||
.query(schema_id, filters.as_ref().map(|f| &f.0));
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
}
|
||||
None => jspg_failure(),
|
||||
@ -131,24 +131,6 @@ pub fn jspg_schemas() -> JsonB {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), pg_extern)]
|
||||
pub fn jspg_stems() -> JsonB {
|
||||
let engine_opt = {
|
||||
let lock = GLOBAL_JSPG.read().unwrap();
|
||||
lock.clone()
|
||||
};
|
||||
|
||||
match engine_opt {
|
||||
Some(engine) => {
|
||||
let stems_json = serde_json::to_value(&engine.database.stems)
|
||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||
let drop = crate::drop::Drop::success_with_val(stems_json);
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
}
|
||||
None => jspg_failure(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), pg_extern(strict))]
|
||||
pub fn jspg_teardown() -> JsonB {
|
||||
let mut lock = GLOBAL_JSPG.write().unwrap();
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
pub mod cache;
|
||||
|
||||
use crate::database::r#type::Type;
|
||||
use crate::database::Database;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
@ -20,21 +21,58 @@ impl Merger {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge(&self, data: Value) -> crate::drop::Drop {
|
||||
pub fn merge(&self, schema_id: &str, data: Value) -> crate::drop::Drop {
|
||||
let mut notifications_queue = Vec::new();
|
||||
|
||||
let result = self.merge_internal(data, &mut notifications_queue);
|
||||
let target_schema = match self.db.schemas.get(schema_id) {
|
||||
Some(s) => Arc::new(s.clone()),
|
||||
None => {
|
||||
return crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||
code: "MERGE_FAILED".to_string(),
|
||||
message: format!("Unknown schema_id: {}", schema_id),
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
cause: None,
|
||||
context: Some(data),
|
||||
schema: None,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
};
|
||||
|
||||
let result = self.merge_internal(target_schema, data.clone(), &mut notifications_queue);
|
||||
|
||||
let val_resolved = match result {
|
||||
Ok(val) => val,
|
||||
Err(msg) => {
|
||||
let mut final_code = "MERGE_FAILED".to_string();
|
||||
let mut final_message = msg.clone();
|
||||
let mut final_cause = None;
|
||||
|
||||
if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&msg) {
|
||||
if let (Some(Value::String(e_msg)), Some(Value::String(e_code))) = (map.get("error"), map.get("code")) {
|
||||
final_message = e_msg.clone();
|
||||
final_code = e_code.clone();
|
||||
let mut cause_parts = Vec::new();
|
||||
if let Some(Value::String(d)) = map.get("detail") {
|
||||
if !d.is_empty() { cause_parts.push(d.clone()); }
|
||||
}
|
||||
if let Some(Value::String(h)) = map.get("hint") {
|
||||
if !h.is_empty() { cause_parts.push(h.clone()); }
|
||||
}
|
||||
if !cause_parts.is_empty() {
|
||||
final_cause = Some(cause_parts.join("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||
code: "MERGE_FAILED".to_string(),
|
||||
message: msg,
|
||||
code: final_code,
|
||||
message: final_message,
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
cause: None,
|
||||
context: None,
|
||||
cause: final_cause,
|
||||
context: Some(data),
|
||||
schema: None,
|
||||
},
|
||||
}]);
|
||||
@ -87,24 +125,35 @@ impl Merger {
|
||||
|
||||
pub(crate) fn merge_internal(
|
||||
&self,
|
||||
schema: Arc<crate::database::schema::Schema>,
|
||||
data: Value,
|
||||
notifications: &mut Vec<String>,
|
||||
) -> Result<Value, String> {
|
||||
match data {
|
||||
Value::Array(items) => self.merge_array(items, notifications),
|
||||
Value::Object(map) => self.merge_object(map, notifications),
|
||||
Value::Array(items) => self.merge_array(schema, items, notifications),
|
||||
Value::Object(map) => self.merge_object(schema, map, notifications),
|
||||
_ => Err("Invalid merge payload: root must be an Object or Array".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_array(
|
||||
&self,
|
||||
schema: Arc<crate::database::schema::Schema>,
|
||||
items: Vec<Value>,
|
||||
notifications: &mut Vec<String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut item_schema = schema.clone();
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &schema.obj.type_ {
|
||||
if t == "array" {
|
||||
if let Some(items_def) = &schema.obj.items {
|
||||
item_schema = items_def.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut resolved_items = Vec::new();
|
||||
for item in items {
|
||||
let resolved = self.merge_internal(item, notifications)?;
|
||||
let resolved = self.merge_internal(item_schema.clone(), item, notifications)?;
|
||||
resolved_items.push(resolved);
|
||||
}
|
||||
Ok(Value::Array(resolved_items))
|
||||
@ -112,6 +161,7 @@ impl Merger {
|
||||
|
||||
fn merge_object(
|
||||
&self,
|
||||
schema: Arc<crate::database::schema::Schema>,
|
||||
obj: serde_json::Map<String, Value>,
|
||||
notifications: &mut Vec<String>,
|
||||
) -> Result<Value, String> {
|
||||
@ -127,25 +177,49 @@ impl Merger {
|
||||
None => return Err(format!("Unknown entity type: {}", type_name)),
|
||||
};
|
||||
|
||||
// 1. Segment the entity: fields in type_def.fields are database fields, others are relationships
|
||||
let compiled_props = match schema.obj.compiled_properties.get() {
|
||||
Some(props) => props,
|
||||
None => return Err("Schema has no compiled properties for merging".to_string()),
|
||||
};
|
||||
|
||||
let mut entity_fields = serde_json::Map::new();
|
||||
let mut entity_objects = serde_json::Map::new();
|
||||
let mut entity_arrays = serde_json::Map::new();
|
||||
let mut entity_objects = std::collections::BTreeMap::new();
|
||||
let mut entity_arrays = std::collections::BTreeMap::new();
|
||||
|
||||
for (k, v) in obj {
|
||||
let is_field = type_def.fields.contains(&k) || k == "created";
|
||||
let typeof_v = match &v {
|
||||
Value::Object(_) => "object",
|
||||
Value::Array(_) => "array",
|
||||
_ => "other",
|
||||
};
|
||||
// Always retain system and unmapped core fields natively implicitly mapped to the Postgres tables
|
||||
if k == "id" || k == "type" || k == "created" {
|
||||
entity_fields.insert(k.clone(), v.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_field {
|
||||
entity_fields.insert(k, v);
|
||||
} else if typeof_v == "object" {
|
||||
entity_objects.insert(k, v);
|
||||
} else if typeof_v == "array" {
|
||||
entity_arrays.insert(k, v);
|
||||
if let Some(prop_schema) = compiled_props.get(&k) {
|
||||
let mut is_edge = false;
|
||||
if let Some(edges) = schema.obj.compiled_edges.get() {
|
||||
if edges.contains_key(&k) {
|
||||
is_edge = true;
|
||||
}
|
||||
}
|
||||
|
||||
if is_edge {
|
||||
let typeof_v = match &v {
|
||||
Value::Object(_) => "object",
|
||||
Value::Array(_) => "array",
|
||||
_ => "field", // Malformed edge data?
|
||||
};
|
||||
if typeof_v == "object" {
|
||||
entity_objects.insert(k.clone(), (v.clone(), prop_schema.clone()));
|
||||
} else if typeof_v == "array" {
|
||||
entity_arrays.insert(k.clone(), (v.clone(), prop_schema.clone()));
|
||||
} else {
|
||||
entity_fields.insert(k.clone(), v.clone());
|
||||
}
|
||||
} else {
|
||||
// Not an edge! It's a raw Postgres column (e.g., JSONB, text[])
|
||||
entity_fields.insert(k.clone(), v.clone());
|
||||
}
|
||||
} else if type_def.fields.contains(&k) {
|
||||
entity_fields.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,99 +228,96 @@ impl Merger {
|
||||
|
||||
let mut entity_change_kind = None;
|
||||
let mut entity_fetched = None;
|
||||
let mut entity_replaces = None;
|
||||
|
||||
// 2. Pre-stage the entity (for non-relationships)
|
||||
if !type_def.relationship {
|
||||
let (fields, kind, fetched) =
|
||||
let (fields, kind, fetched, replaces) =
|
||||
self.stage_entity(entity_fields.clone(), type_def, &user_id, ×tamp)?;
|
||||
entity_fields = fields;
|
||||
entity_change_kind = kind;
|
||||
entity_fetched = fetched;
|
||||
entity_replaces = replaces;
|
||||
}
|
||||
|
||||
let mut entity_response = serde_json::Map::new();
|
||||
|
||||
// 3. Handle related objects
|
||||
for (relation_name, relative_val) in entity_objects {
|
||||
for (relation_name, (relative_val, rel_schema)) in entity_objects {
|
||||
let mut relative = match relative_val {
|
||||
Value::Object(m) => m,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Attempt to extract relative object type name
|
||||
let relative_type_name = match relative.get("type").and_then(|v| v.as_str()) {
|
||||
Some(t) => t,
|
||||
Some(t) => t.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let relative_keys: Vec<String> = relative.keys().cloned().collect();
|
||||
if let Some(compiled_edges) = schema.obj.compiled_edges.get() {
|
||||
println!("Compiled Edges keys for relation {}: {:?}", relation_name, compiled_edges.keys().collect::<Vec<_>>());
|
||||
if let Some(edge) = compiled_edges.get(&relation_name) {
|
||||
println!("FOUND EDGE {} -> {:?}", relation_name, edge.constraint);
|
||||
if let Some(relation) = self.db.relations.get(&edge.constraint) {
|
||||
let parent_is_source = edge.forward;
|
||||
|
||||
if parent_is_source {
|
||||
if !relative.contains_key("organization_id") {
|
||||
if let Some(org_id) = entity_fields.get("organization_id") {
|
||||
relative.insert("organization_id".to_string(), org_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Call central Database O(1) graph logic
|
||||
let relative_relation = self.db.get_relation(
|
||||
&type_def.name,
|
||||
relative_type_name,
|
||||
&relation_name,
|
||||
Some(&relative_keys),
|
||||
);
|
||||
let mut merged_relative = match self.merge_internal(rel_schema.clone(), Value::Object(relative), notifications)? {
|
||||
Value::Object(m) => m,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if let Some(relation) = relative_relation {
|
||||
let parent_is_source = type_def.hierarchy.contains(&relation.source_type);
|
||||
merged_relative.insert(
|
||||
"type".to_string(),
|
||||
Value::String(relative_type_name),
|
||||
);
|
||||
|
||||
if parent_is_source {
|
||||
// Parent holds FK to Child. Child MUST be generated FIRST.
|
||||
if !relative.contains_key("organization_id") {
|
||||
if let Some(org_id) = entity_fields.get("organization_id") {
|
||||
relative.insert("organization_id".to_string(), org_id.clone());
|
||||
Self::apply_entity_relation(
|
||||
&mut entity_fields,
|
||||
&relation.source_columns,
|
||||
&relation.destination_columns,
|
||||
&merged_relative,
|
||||
);
|
||||
entity_response.insert(relation_name, Value::Object(merged_relative));
|
||||
} else {
|
||||
if !relative.contains_key("organization_id") {
|
||||
if let Some(org_id) = entity_fields.get("organization_id") {
|
||||
relative.insert("organization_id".to_string(), org_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Self::apply_entity_relation(
|
||||
&mut relative,
|
||||
&relation.source_columns,
|
||||
&relation.destination_columns,
|
||||
&entity_fields,
|
||||
);
|
||||
|
||||
let merged_relative = match self.merge_internal(rel_schema.clone(), Value::Object(relative), notifications)? {
|
||||
Value::Object(m) => m,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
entity_response.insert(relation_name, Value::Object(merged_relative));
|
||||
}
|
||||
}
|
||||
|
||||
let merged_relative = match self.merge_internal(Value::Object(relative), notifications)? {
|
||||
Value::Object(m) => m,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
Self::apply_entity_relation(
|
||||
&mut entity_fields,
|
||||
&relation.source_columns,
|
||||
&relation.destination_columns,
|
||||
&merged_relative,
|
||||
);
|
||||
entity_response.insert(relation_name, Value::Object(merged_relative));
|
||||
} else {
|
||||
// Child holds FK back to Parent.
|
||||
if !relative.contains_key("organization_id") {
|
||||
if let Some(org_id) = entity_fields.get("organization_id") {
|
||||
relative.insert("organization_id".to_string(), org_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Self::apply_entity_relation(
|
||||
&mut relative,
|
||||
&relation.source_columns,
|
||||
&relation.destination_columns,
|
||||
&entity_fields,
|
||||
);
|
||||
|
||||
let merged_relative = match self.merge_internal(Value::Object(relative), notifications)? {
|
||||
Value::Object(m) => m,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
entity_response.insert(relation_name, Value::Object(merged_relative));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Post-stage the entity (for relationships)
|
||||
if type_def.relationship {
|
||||
let (fields, kind, fetched) =
|
||||
let (fields, kind, fetched, replaces) =
|
||||
self.stage_entity(entity_fields.clone(), type_def, &user_id, ×tamp)?;
|
||||
entity_fields = fields;
|
||||
entity_change_kind = kind;
|
||||
entity_fetched = fetched;
|
||||
entity_replaces = replaces;
|
||||
}
|
||||
|
||||
// 5. Process the main entity fields
|
||||
self.merge_entity_fields(
|
||||
entity_change_kind.as_deref().unwrap_or(""),
|
||||
&type_name,
|
||||
@ -255,13 +326,11 @@ impl Merger {
|
||||
entity_fetched.as_ref(),
|
||||
)?;
|
||||
|
||||
// Add main entity fields to response
|
||||
for (k, v) in &entity_fields {
|
||||
entity_response.insert(k.clone(), v.clone());
|
||||
}
|
||||
|
||||
// 6. Handle related arrays
|
||||
for (relation_name, relative_val) in entity_arrays {
|
||||
for (relation_name, (relative_val, rel_schema)) in entity_arrays {
|
||||
let relative_arr = match relative_val {
|
||||
Value::Array(a) => a,
|
||||
_ => continue,
|
||||
@ -271,64 +340,58 @@ impl Merger {
|
||||
continue;
|
||||
}
|
||||
|
||||
let first_relative = match &relative_arr[0] {
|
||||
Value::Object(m) => m,
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(compiled_edges) = schema.obj.compiled_edges.get() {
|
||||
if let Some(edge) = compiled_edges.get(&relation_name) {
|
||||
if let Some(relation) = self.db.relations.get(&edge.constraint) {
|
||||
let mut relative_responses = Vec::new();
|
||||
for relative_item_val in relative_arr {
|
||||
if let Value::Object(mut relative_item) = relative_item_val {
|
||||
if !relative_item.contains_key("organization_id") {
|
||||
if let Some(org_id) = entity_fields.get("organization_id") {
|
||||
relative_item.insert("organization_id".to_string(), org_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to extract relative object type name
|
||||
let relative_type_name = match first_relative.get("type").and_then(|v| v.as_str()) {
|
||||
Some(t) => t,
|
||||
None => continue,
|
||||
};
|
||||
Self::apply_entity_relation(
|
||||
&mut relative_item,
|
||||
&relation.source_columns,
|
||||
&relation.destination_columns,
|
||||
&entity_fields,
|
||||
);
|
||||
|
||||
let relative_keys: Vec<String> = first_relative.keys().cloned().collect();
|
||||
let mut item_schema = rel_schema.clone();
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &rel_schema.obj.type_ {
|
||||
if t == "array" {
|
||||
if let Some(items_def) = &rel_schema.obj.items {
|
||||
item_schema = items_def.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call central Database O(1) graph logic
|
||||
let relative_relation = self.db.get_relation(
|
||||
&type_def.name,
|
||||
relative_type_name,
|
||||
&relation_name,
|
||||
Some(&relative_keys),
|
||||
);
|
||||
let merged_relative =
|
||||
match self.merge_internal(item_schema, Value::Object(relative_item), notifications)? {
|
||||
Value::Object(m) => m,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if let Some(relation) = relative_relation {
|
||||
let mut relative_responses = Vec::new();
|
||||
for relative_item_val in relative_arr {
|
||||
if let Value::Object(mut relative_item) = relative_item_val {
|
||||
if !relative_item.contains_key("organization_id") {
|
||||
if let Some(org_id) = entity_fields.get("organization_id") {
|
||||
relative_item.insert("organization_id".to_string(), org_id.clone());
|
||||
relative_responses.push(Value::Object(merged_relative));
|
||||
}
|
||||
}
|
||||
|
||||
Self::apply_entity_relation(
|
||||
&mut relative_item,
|
||||
&relation.source_columns,
|
||||
&relation.destination_columns,
|
||||
&entity_fields,
|
||||
);
|
||||
|
||||
let merged_relative =
|
||||
match self.merge_internal(Value::Object(relative_item), notifications)? {
|
||||
Value::Object(m) => m,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
relative_responses.push(Value::Object(merged_relative));
|
||||
entity_response.insert(relation_name, Value::Array(relative_responses));
|
||||
}
|
||||
}
|
||||
entity_response.insert(relation_name, Value::Array(relative_responses));
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Perform change tracking
|
||||
// 7. Perform change tracking dynamically suppressing noise based on type bounds!
|
||||
let notify_sql = self.merge_entity_change(
|
||||
type_def,
|
||||
&entity_fields,
|
||||
entity_fetched.as_ref(),
|
||||
entity_change_kind.as_deref(),
|
||||
&user_id,
|
||||
×tamp,
|
||||
entity_replaces.as_deref(),
|
||||
)?;
|
||||
|
||||
if let Some(sql) = notify_sql {
|
||||
@ -360,13 +423,42 @@ impl Merger {
|
||||
serde_json::Map<String, Value>,
|
||||
Option<String>,
|
||||
Option<serde_json::Map<String, Value>>,
|
||||
Option<String>,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let type_name = type_def.name.as_str();
|
||||
|
||||
// 🚀 Anchor Short-Circuit Optimization
|
||||
// An anchor is STRICTLY a struct containing merely an `id` and `type`.
|
||||
// We aggressively bypass Database SPI `SELECT` fetches because there are no primitive
|
||||
// mutations to apply to the row. PostgreSQL inherently protects relationships via Foreign Keys downstream.
|
||||
let is_anchor = entity_fields.len() == 2
|
||||
&& entity_fields.contains_key("id")
|
||||
&& entity_fields.contains_key("type");
|
||||
|
||||
let has_valid_id = entity_fields
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map_or(false, |s| !s.is_empty());
|
||||
|
||||
if is_anchor && has_valid_id {
|
||||
return Ok((entity_fields, None, None, None));
|
||||
}
|
||||
|
||||
let entity_fetched = self.fetch_entity(&entity_fields, type_def)?;
|
||||
|
||||
let mut replaces_id = None;
|
||||
if let Some(ref fetched_row) = entity_fetched {
|
||||
let provided_id = entity_fields.get("id").and_then(|v| v.as_str());
|
||||
let fetched_id = fetched_row.get("id").and_then(|v| v.as_str());
|
||||
if let (Some(pid), Some(fid)) = (provided_id, fetched_id) {
|
||||
if !pid.is_empty() && pid != fid {
|
||||
replaces_id = Some(pid.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let system_keys = vec![
|
||||
"id".to_string(),
|
||||
"type".to_string(),
|
||||
@ -454,7 +546,7 @@ impl Merger {
|
||||
entity_fields = new_fields;
|
||||
}
|
||||
|
||||
Ok((entity_fields, entity_change_kind, entity_fetched))
|
||||
Ok((entity_fields, entity_change_kind, entity_fetched, replaces_id))
|
||||
}
|
||||
|
||||
fn fetch_entity(
|
||||
@ -509,11 +601,14 @@ impl Merger {
|
||||
template
|
||||
};
|
||||
|
||||
let where_clause = if let Some(id) = id_val {
|
||||
format!("WHERE t1.id = {}", Self::quote_literal(id))
|
||||
} else if lookup_complete {
|
||||
let mut lookup_predicates = Vec::new();
|
||||
let mut where_parts = Vec::new();
|
||||
|
||||
if let Some(id) = id_val {
|
||||
where_parts.push(format!("t1.id = {}", Self::quote_literal(id)));
|
||||
}
|
||||
|
||||
if lookup_complete {
|
||||
let mut lookup_predicates = Vec::new();
|
||||
for column in &entity_type.lookup_fields {
|
||||
let val = entity_fields.get(column).unwrap_or(&Value::Null);
|
||||
if column == "type" {
|
||||
@ -522,10 +617,14 @@ impl Merger {
|
||||
lookup_predicates.push(format!("\"{}\" = {}", column, Self::quote_literal(val)));
|
||||
}
|
||||
}
|
||||
format!("WHERE {}", lookup_predicates.join(" AND "))
|
||||
} else {
|
||||
where_parts.push(format!("({})", lookup_predicates.join(" AND ")));
|
||||
}
|
||||
|
||||
if where_parts.is_empty() {
|
||||
return Ok(None);
|
||||
};
|
||||
}
|
||||
|
||||
let where_clause = format!("WHERE {}", where_parts.join(" OR "));
|
||||
|
||||
let final_sql = format!("{} {}", fetch_sql_template, where_clause);
|
||||
|
||||
@ -621,11 +720,7 @@ impl Merger {
|
||||
for key in &sorted_keys {
|
||||
columns.push(format!("\"{}\"", key));
|
||||
let val = entity_pairs.get(key).unwrap();
|
||||
if val.as_str() == Some("") {
|
||||
values.push("NULL".to_string());
|
||||
} else {
|
||||
values.push(Self::quote_literal(val));
|
||||
}
|
||||
values.push(Self::format_sql_value(val, key, entity_type));
|
||||
}
|
||||
|
||||
if columns.is_empty() {
|
||||
@ -640,8 +735,7 @@ impl Merger {
|
||||
);
|
||||
self
|
||||
.db
|
||||
.execute(&sql, None)
|
||||
.map_err(|e| format!("SPI Error in INSERT: {:?}", e))?;
|
||||
.execute(&sql, None)?;
|
||||
} else if change_kind == "update" || change_kind == "delete" {
|
||||
entity_pairs.remove("id");
|
||||
entity_pairs.remove("type");
|
||||
@ -659,7 +753,11 @@ impl Merger {
|
||||
if val.as_str() == Some("") {
|
||||
set_clauses.push(format!("\"{}\" = NULL", key));
|
||||
} else {
|
||||
set_clauses.push(format!("\"{}\" = {}", key, Self::quote_literal(val)));
|
||||
set_clauses.push(format!(
|
||||
"\"{}\" = {}",
|
||||
key,
|
||||
Self::format_sql_value(val, key, entity_type)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -671,8 +769,7 @@ impl Merger {
|
||||
);
|
||||
self
|
||||
.db
|
||||
.execute(&sql, None)
|
||||
.map_err(|e| format!("SPI Error in UPDATE: {:?}", e))?;
|
||||
.execute(&sql, None)?;
|
||||
}
|
||||
}
|
||||
|
||||
@ -681,11 +778,13 @@ impl Merger {
|
||||
|
||||
fn merge_entity_change(
|
||||
&self,
|
||||
type_obj: &Type,
|
||||
entity_fields: &serde_json::Map<String, Value>,
|
||||
entity_fetched: Option<&serde_json::Map<String, Value>>,
|
||||
entity_change_kind: Option<&str>,
|
||||
user_id: &str,
|
||||
timestamp: &str,
|
||||
replaces_id: Option<&str>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let change_kind = match entity_change_kind {
|
||||
Some(k) => k,
|
||||
@ -695,7 +794,8 @@ impl Merger {
|
||||
let id_str = entity_fields.get("id").unwrap();
|
||||
let type_name = entity_fields.get("type").unwrap();
|
||||
|
||||
let mut changes = serde_json::Map::new();
|
||||
let mut old_vals = serde_json::Map::new();
|
||||
let mut new_vals = serde_json::Map::new();
|
||||
let is_update = change_kind == "update" || change_kind == "delete";
|
||||
|
||||
if !is_update {
|
||||
@ -708,7 +808,7 @@ impl Merger {
|
||||
];
|
||||
for (k, v) in entity_fields {
|
||||
if !system_keys.contains(k) {
|
||||
changes.insert(k.clone(), v.clone());
|
||||
new_vals.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -725,12 +825,13 @@ impl Merger {
|
||||
if let Some(fetched) = entity_fetched {
|
||||
let old_val = fetched.get(k).unwrap_or(&Value::Null);
|
||||
if v != old_val {
|
||||
changes.insert(k.clone(), v.clone());
|
||||
new_vals.insert(k.clone(), v.clone());
|
||||
old_vals.insert(k.clone(), old_val.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
changes.insert("type".to_string(), type_name.clone());
|
||||
new_vals.insert("type".to_string(), type_name.clone());
|
||||
}
|
||||
|
||||
let mut complete = entity_fields.clone();
|
||||
@ -744,33 +845,49 @@ impl Merger {
|
||||
}
|
||||
}
|
||||
|
||||
let new_val_obj = Value::Object(new_vals);
|
||||
let old_val_obj = if old_vals.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
Value::Object(old_vals)
|
||||
};
|
||||
|
||||
let mut notification = serde_json::Map::new();
|
||||
notification.insert("complete".to_string(), Value::Object(complete));
|
||||
if is_update {
|
||||
notification.insert("changes".to_string(), Value::Object(changes.clone()));
|
||||
notification.insert("new".to_string(), new_val_obj.clone());
|
||||
|
||||
if old_val_obj != Value::Null {
|
||||
notification.insert("old".to_string(), old_val_obj.clone());
|
||||
}
|
||||
|
||||
if let Some(rep) = replaces_id {
|
||||
notification.insert("replaces".to_string(), Value::String(rep.to_string()));
|
||||
}
|
||||
|
||||
let change_sql = format!(
|
||||
"INSERT INTO agreego.change (changes, entity_id, id, kind, modified_at, modified_by) VALUES ({}, {}, {}, {}, {}, {})",
|
||||
Self::quote_literal(&Value::Object(changes)),
|
||||
Self::quote_literal(id_str),
|
||||
Self::quote_literal(&Value::String(uuid::Uuid::new_v4().to_string())),
|
||||
Self::quote_literal(&Value::String(change_kind.to_string())),
|
||||
Self::quote_literal(&Value::String(timestamp.to_string())),
|
||||
Self::quote_literal(&Value::String(user_id.to_string()))
|
||||
);
|
||||
let mut notify_sql = None;
|
||||
if type_obj.historical {
|
||||
let change_sql = format!(
|
||||
"INSERT INTO agreego.change (\"old\", \"new\", entity_id, id, kind, modified_at, modified_by) VALUES ({}, {}, {}, {}, {}, {}, {})",
|
||||
Self::quote_literal(&old_val_obj),
|
||||
Self::quote_literal(&new_val_obj),
|
||||
Self::quote_literal(id_str),
|
||||
Self::quote_literal(&Value::String(uuid::Uuid::new_v4().to_string())),
|
||||
Self::quote_literal(&Value::String(change_kind.to_string())),
|
||||
Self::quote_literal(&Value::String(timestamp.to_string())),
|
||||
Self::quote_literal(&Value::String(user_id.to_string()))
|
||||
);
|
||||
|
||||
let notify_sql = format!(
|
||||
"SELECT pg_notify('entity', {})",
|
||||
Self::quote_literal(&Value::String(Value::Object(notification).to_string()))
|
||||
);
|
||||
self.db.execute(&change_sql, None)?;
|
||||
}
|
||||
|
||||
self
|
||||
.db
|
||||
.execute(&change_sql, None)
|
||||
.map_err(|e| format!("Executor Error in change: {:?}", e))?;
|
||||
if type_obj.notify {
|
||||
notify_sql = Some(format!(
|
||||
"SELECT pg_notify('entity', {})",
|
||||
Self::quote_literal(&Value::String(Value::Object(notification).to_string()))
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(notify_sql))
|
||||
Ok(notify_sql)
|
||||
}
|
||||
|
||||
fn compare_entities(
|
||||
@ -822,6 +939,34 @@ impl Merger {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_sql_value(val: &Value, key: &str, entity_type: &Type) -> String {
|
||||
if val.as_str() == Some("") {
|
||||
return "NULL".to_string();
|
||||
}
|
||||
|
||||
let mut is_pg_array = false;
|
||||
if let Some(field_types_map) = entity_type.field_types.as_ref().and_then(|v| v.as_object()) {
|
||||
if let Some(t_val) = field_types_map.get(key) {
|
||||
if let Some(t_str) = t_val.as_str() {
|
||||
if t_str.starts_with('_') {
|
||||
is_pg_array = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_pg_array && val.is_array() {
|
||||
let mut s = val.to_string();
|
||||
if s.starts_with('[') && s.ends_with(']') {
|
||||
s.replace_range(0..1, "{");
|
||||
s.replace_range(s.len() - 1..s.len(), "}");
|
||||
}
|
||||
Self::quote_literal(&Value::String(s))
|
||||
} else {
|
||||
Self::quote_literal(val)
|
||||
}
|
||||
}
|
||||
|
||||
fn quote_literal(val: &Value) -> String {
|
||||
match val {
|
||||
Value::Null => "NULL".to_string(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -21,7 +21,6 @@ impl Queryer {
|
||||
pub fn query(
|
||||
&self,
|
||||
schema_id: &str,
|
||||
stem_opt: Option<&str>,
|
||||
filters: Option<&serde_json::Value>,
|
||||
) -> crate::drop::Drop {
|
||||
let filters_map = filters.and_then(|f| f.as_object());
|
||||
@ -36,18 +35,17 @@ impl Queryer {
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(), // filters apply to the root query
|
||||
cause: Some(msg),
|
||||
context: filters.map(|f| vec![f.to_string()]),
|
||||
context: filters.cloned(),
|
||||
schema: Some(schema_id.to_string()),
|
||||
},
|
||||
}]);
|
||||
}
|
||||
};
|
||||
|
||||
let stem_key = stem_opt.unwrap_or("/");
|
||||
let cache_key = format!("{}(Stem:{}):{}", schema_id, stem_key, filter_keys.join(","));
|
||||
let cache_key = format!("{}:{}", schema_id, filter_keys.join(","));
|
||||
|
||||
// 2. Fetch from cache or compile
|
||||
let sql = match self.get_or_compile_sql(&cache_key, schema_id, stem_opt, &filter_keys) {
|
||||
let sql = match self.get_or_compile_sql(&cache_key, schema_id, &filter_keys) {
|
||||
Ok(sql) => sql,
|
||||
Err(drop) => return drop,
|
||||
};
|
||||
@ -56,6 +54,45 @@ impl Queryer {
|
||||
self.execute_sql(schema_id, &sql, &args)
|
||||
}
|
||||
|
||||
fn extract_filters(
|
||||
prefix: String,
|
||||
val: &serde_json::Value,
|
||||
entries: &mut Vec<(String, serde_json::Value)>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(obj) = val.as_object() {
|
||||
let mut is_op_obj = false;
|
||||
if let Some(first_key) = obj.keys().next() {
|
||||
if first_key.starts_with('$') {
|
||||
is_op_obj = true;
|
||||
}
|
||||
}
|
||||
|
||||
if is_op_obj {
|
||||
for (op, op_val) in obj {
|
||||
if !op.starts_with('$') {
|
||||
return Err(format!("Filter operator must start with '$', got: {}", op));
|
||||
}
|
||||
entries.push((format!("{}:{}", prefix, op), op_val.clone()));
|
||||
}
|
||||
} else {
|
||||
for (k, v) in obj {
|
||||
let next_prefix = if prefix.is_empty() {
|
||||
k.clone()
|
||||
} else {
|
||||
format!("{}/{}", prefix, k)
|
||||
};
|
||||
Self::extract_filters(next_prefix, v, entries)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Filter for path '{}' must be an operator object like {{$eq: ...}} or a nested map.",
|
||||
prefix
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_filter_entries(
|
||||
&self,
|
||||
filters_map: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
@ -63,19 +100,7 @@ impl Queryer {
|
||||
let mut filter_entries: Vec<(String, serde_json::Value)> = Vec::new();
|
||||
if let Some(fm) = filters_map {
|
||||
for (key, val) in fm {
|
||||
if let Some(obj) = val.as_object() {
|
||||
for (op, op_val) in obj {
|
||||
if !op.starts_with('$') {
|
||||
return Err(format!("Filter operator must start with '$', got: {}", op));
|
||||
}
|
||||
filter_entries.push((format!("{}:{}", key, op), op_val.clone()));
|
||||
}
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Filter for field '{}' must be an object with operators like $eq, $in, etc.",
|
||||
key
|
||||
));
|
||||
}
|
||||
Self::extract_filters(key.clone(), val, &mut filter_entries)?;
|
||||
}
|
||||
}
|
||||
filter_entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
@ -90,15 +115,19 @@ impl Queryer {
|
||||
&self,
|
||||
cache_key: &str,
|
||||
schema_id: &str,
|
||||
stem_opt: Option<&str>,
|
||||
filter_keys: &[String],
|
||||
) -> Result<String, crate::drop::Drop> {
|
||||
if let Some(cached_sql) = self.cache.get(cache_key) {
|
||||
return Ok(cached_sql.value().clone());
|
||||
}
|
||||
|
||||
let compiler = compiler::SqlCompiler::new(self.db.clone());
|
||||
match compiler.compile(schema_id, stem_opt, filter_keys) {
|
||||
let compiler = compiler::Compiler {
|
||||
db: &self.db,
|
||||
filter_keys: filter_keys,
|
||||
alias_counter: 0,
|
||||
};
|
||||
|
||||
match compiler.compile(schema_id, filter_keys) {
|
||||
Ok(compiled_sql) => {
|
||||
self
|
||||
.cache
|
||||
@ -138,7 +167,7 @@ impl Queryer {
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
cause: Some(format!("Expected array, got {}", other)),
|
||||
context: Some(vec![sql.to_string()]),
|
||||
context: Some(serde_json::json!([sql])),
|
||||
schema: Some(schema_id.to_string()),
|
||||
},
|
||||
}]),
|
||||
@ -148,7 +177,7 @@ impl Queryer {
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
cause: Some(format!("SPI error in queryer: {}", e)),
|
||||
context: Some(vec![sql.to_string()]),
|
||||
context: Some(serde_json::json!([sql])),
|
||||
schema: Some(schema_id.to_string()),
|
||||
},
|
||||
}]),
|
||||
|
||||
@ -1463,18 +1463,6 @@ fn test_queryer_0_8() {
|
||||
crate::tests::runner::run_test_case(&path, 0, 8).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queryer_0_9() {
|
||||
let path = format!("{}/fixtures/queryer.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 9).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queryer_0_10() {
|
||||
let path = format!("{}/fixtures/queryer.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 10).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_0_0() {
|
||||
let path = format!("{}/fixtures/not.json", env!("CARGO_MANIFEST_DIR"));
|
||||
@ -2939,6 +2927,36 @@ fn test_minimum_1_6() {
|
||||
crate::tests::runner::run_test_case(&path, 1, 6).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paths_0_0() {
|
||||
let path = format!("{}/fixtures/paths.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 0).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paths_0_1() {
|
||||
let path = format!("{}/fixtures/paths.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 1).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paths_0_2() {
|
||||
let path = format!("{}/fixtures/paths.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 2).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paths_0_3() {
|
||||
let path = format!("{}/fixtures/paths.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 3).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paths_0_4() {
|
||||
let path = format!("{}/fixtures/paths.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 4).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_one_of_0_0() {
|
||||
let path = format!("{}/fixtures/oneOf.json", env!("CARGO_MANIFEST_DIR"));
|
||||
@ -3449,12 +3467,6 @@ fn test_if_then_else_13_1() {
|
||||
crate::tests::runner::run_test_case(&path, 13, 1).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stems_0_0() {
|
||||
let path = format!("{}/fixtures/stems.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 0).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_string_0_0() {
|
||||
let path = format!("{}/fixtures/emptyString.json", env!("CARGO_MANIFEST_DIR"));
|
||||
@ -8566,3 +8578,27 @@ fn test_merger_0_7() {
|
||||
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 7).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merger_0_8() {
|
||||
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 8).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merger_0_9() {
|
||||
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 9).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merger_0_10() {
|
||||
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 10).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merger_0_11() {
|
||||
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
|
||||
crate::tests::runner::run_test_case(&path, 0, 11).unwrap();
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ fn test_library_api() {
|
||||
// 1. Initially, schemas are not cached.
|
||||
|
||||
// Expected uninitialized drop format: errors + null response
|
||||
let uninitialized_drop = jspg_validate("test_schema", JsonB(json!({})));
|
||||
let uninitialized_drop = jspg_validate("source_schema", JsonB(json!({})));
|
||||
assert_eq!(
|
||||
uninitialized_drop.0,
|
||||
json!({
|
||||
@ -27,17 +27,44 @@ fn test_library_api() {
|
||||
let db_json = json!({
|
||||
"puncs": [],
|
||||
"enums": [],
|
||||
"relations": [],
|
||||
"types": [{
|
||||
"schemas": [{
|
||||
"$id": "test_schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}]
|
||||
}]
|
||||
"relations": [
|
||||
{
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"type": "relation",
|
||||
"constraint": "fk_test_target",
|
||||
"source_type": "source_schema",
|
||||
"source_columns": ["target_id"],
|
||||
"destination_type": "target_schema",
|
||||
"destination_columns": ["id"],
|
||||
"prefix": "target"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
{
|
||||
"name": "source_schema",
|
||||
"hierarchy": ["source_schema", "entity"],
|
||||
"schemas": [{
|
||||
"$id": "source_schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"target": { "$ref": "target_schema" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}]
|
||||
},
|
||||
{
|
||||
"name": "target_schema",
|
||||
"hierarchy": ["target_schema", "entity"],
|
||||
"schemas": [{
|
||||
"$id": "target_schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": { "type": "number" }
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let cache_drop = jspg_setup(JsonB(db_json));
|
||||
@ -56,20 +83,39 @@ fn test_library_api() {
|
||||
json!({
|
||||
"type": "drop",
|
||||
"response": {
|
||||
"test_schema": {
|
||||
"$id": "test_schema",
|
||||
"source_schema": {
|
||||
"$id": "source_schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" }
|
||||
"name": { "type": "string" },
|
||||
"target": {
|
||||
"$ref": "target_schema",
|
||||
"compiledProperties": ["value"]
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
"required": ["name"],
|
||||
"compiledProperties": ["name", "target"],
|
||||
"compiledEdges": {
|
||||
"target": {
|
||||
"constraint": "fk_test_target",
|
||||
"forward": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"target_schema": {
|
||||
"$id": "target_schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": { "type": "number" }
|
||||
},
|
||||
"compiledProperties": ["value"]
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 4. Validate Happy Path
|
||||
let happy_drop = jspg_validate("test_schema", JsonB(json!({"name": "Neo"})));
|
||||
let happy_drop = jspg_validate("source_schema", JsonB(json!({"name": "Neo"})));
|
||||
assert_eq!(
|
||||
happy_drop.0,
|
||||
json!({
|
||||
@ -79,7 +125,7 @@ fn test_library_api() {
|
||||
);
|
||||
|
||||
// 5. Validate Unhappy Path
|
||||
let unhappy_drop = jspg_validate("test_schema", JsonB(json!({"wrong": "data"})));
|
||||
let unhappy_drop = jspg_validate("source_schema", JsonB(json!({"wrong": "data"})));
|
||||
assert_eq!(
|
||||
unhappy_drop.0,
|
||||
json!({
|
||||
@ -88,12 +134,12 @@ fn test_library_api() {
|
||||
{
|
||||
"code": "REQUIRED_FIELD_MISSING",
|
||||
"message": "Missing name",
|
||||
"details": { "path": "/name" }
|
||||
"details": { "path": "name" }
|
||||
},
|
||||
{
|
||||
"code": "STRICT_PROPERTY_VIOLATION",
|
||||
"message": "Unexpected property 'wrong'",
|
||||
"details": { "path": "/wrong" }
|
||||
"details": { "path": "wrong" }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@ -16,9 +16,6 @@ pub struct Case {
|
||||
pub schema_id: String,
|
||||
|
||||
// For Query
|
||||
#[serde(default)]
|
||||
pub stem: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub filters: Option<serde_json::Value>,
|
||||
|
||||
@ -38,7 +35,7 @@ fn default_action() -> String {
|
||||
}
|
||||
|
||||
impl Case {
|
||||
pub fn run_compile(&self, db: Arc<Database>) -> Result<(), String> {
|
||||
pub fn run_compile(&self, _db: Arc<Database>) -> Result<(), String> {
|
||||
let expected_success = self.expect.as_ref().map(|e| e.success).unwrap_or(false);
|
||||
|
||||
// We assume db has already been setup and compiled successfully by runner.rs's `jspg_setup`
|
||||
@ -52,24 +49,6 @@ impl Case {
|
||||
));
|
||||
}
|
||||
|
||||
// Assert stems
|
||||
if let Some(expect) = &self.expect {
|
||||
if let Some(expected_stems) = &expect.stems {
|
||||
// Convert the Db stems (HashMap<String, HashMap<String, Arc<Stem>>>) to matching JSON shape
|
||||
let db_stems_json = serde_json::to_value(&db.stems).unwrap();
|
||||
let expect_stems_json = serde_json::to_value(expected_stems).unwrap();
|
||||
|
||||
if db_stems_json != expect_stems_json {
|
||||
let expected_pretty = serde_json::to_string_pretty(&expect_stems_json).unwrap();
|
||||
let got_pretty = serde_json::to_string_pretty(&db_stems_json).unwrap();
|
||||
return Err(format!(
|
||||
"Stem validation failed.\nExpected:\n{}\n\nGot:\n{}",
|
||||
expected_pretty, got_pretty
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -120,7 +99,7 @@ impl Case {
|
||||
let merger = Merger::new(db.clone());
|
||||
|
||||
let test_data = self.data.clone().unwrap_or(Value::Null);
|
||||
let result = merger.merge(test_data);
|
||||
let result = merger.merge(&self.schema_id, test_data);
|
||||
|
||||
let expected_success = self.expect.as_ref().map(|e| e.success).unwrap_or(false);
|
||||
let got_success = result.errors.is_empty();
|
||||
@ -158,8 +137,7 @@ impl Case {
|
||||
use crate::queryer::Queryer;
|
||||
let queryer = Queryer::new(db.clone());
|
||||
|
||||
let stem_opt = self.stem.as_deref();
|
||||
let result = queryer.query(&self.schema_id, stem_opt, self.filters.as_ref());
|
||||
let result = queryer.query(&self.schema_id, self.filters.as_ref());
|
||||
|
||||
let expected_success = self.expect.as_ref().map(|e| e.success).unwrap_or(false);
|
||||
let got_success = result.errors.is_empty();
|
||||
|
||||
@ -2,7 +2,6 @@ pub mod pattern;
|
||||
pub mod sql;
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
@ -16,7 +15,6 @@ pub struct Expect {
|
||||
pub success: bool,
|
||||
pub result: Option<serde_json::Value>,
|
||||
pub errors: Option<Vec<serde_json::Value>>,
|
||||
pub stems: Option<HashMap<String, HashMap<String, serde_json::Value>>>,
|
||||
#[serde(default)]
|
||||
pub sql: Option<Vec<SqlExpectation>>,
|
||||
}
|
||||
|
||||
@ -191,9 +191,9 @@ impl Expect {
|
||||
}
|
||||
Expr::Function(func) => {
|
||||
if let sqlparser::ast::FunctionArguments::List(args) = &func.args {
|
||||
if let Some(sqlparser::ast::FunctionArg::Unnamed(sqlparser::ast::FunctionArgExpr::Expr(
|
||||
e,
|
||||
))) = args.args.get(0)
|
||||
if let Some(sqlparser::ast::FunctionArg::Unnamed(
|
||||
sqlparser::ast::FunctionArgExpr::Expr(e),
|
||||
)) = args.args.get(0)
|
||||
{
|
||||
Self::validate_expr(e, available_aliases, sql)?;
|
||||
}
|
||||
|
||||
@ -41,6 +41,14 @@ impl<'a> ValidationContext<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn join_path(&self, key: &str) -> String {
|
||||
if self.path.is_empty() {
|
||||
key.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.path, key)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derive(
|
||||
&self,
|
||||
schema: &'a Schema,
|
||||
|
||||
@ -91,12 +91,17 @@ impl<'a> ValidationContext<'a> {
|
||||
if let Some(ref prefix) = self.schema.prefix_items {
|
||||
for (i, sub_schema) in prefix.iter().enumerate() {
|
||||
if i < len {
|
||||
let path = format!("{}/{}", self.path, i);
|
||||
if let Some(child_instance) = arr.get(i) {
|
||||
let mut item_path = self.join_path(&i.to_string());
|
||||
if let Some(obj) = child_instance.as_object() {
|
||||
if let Some(id_str) = obj.get("id").and_then(|v| v.as_str()) {
|
||||
item_path = self.join_path(id_str);
|
||||
}
|
||||
}
|
||||
let derived = self.derive(
|
||||
sub_schema,
|
||||
child_instance,
|
||||
&path,
|
||||
&item_path,
|
||||
HashSet::new(),
|
||||
self.extensible,
|
||||
false,
|
||||
@ -112,12 +117,17 @@ impl<'a> ValidationContext<'a> {
|
||||
|
||||
if let Some(ref items_schema) = self.schema.items {
|
||||
for i in validation_index..len {
|
||||
let path = format!("{}/{}", self.path, i);
|
||||
if let Some(child_instance) = arr.get(i) {
|
||||
let mut item_path = self.join_path(&i.to_string());
|
||||
if let Some(obj) = child_instance.as_object() {
|
||||
if let Some(id_str) = obj.get("id").and_then(|v| v.as_str()) {
|
||||
item_path = self.join_path(id_str);
|
||||
}
|
||||
}
|
||||
let derived = self.derive(
|
||||
items_schema,
|
||||
child_instance,
|
||||
&path,
|
||||
&item_path,
|
||||
HashSet::new(),
|
||||
self.extensible,
|
||||
false,
|
||||
|
||||
@ -44,7 +44,7 @@ impl<'a> ValidationContext<'a> {
|
||||
result.errors.push(ValidationError {
|
||||
code: "STRICT_PROPERTY_VIOLATION".to_string(),
|
||||
message: format!("Unexpected property '{}'", key),
|
||||
path: format!("{}/{}", self.path, key),
|
||||
path: self.join_path(key),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -53,10 +53,18 @@ impl<'a> ValidationContext<'a> {
|
||||
if let Some(arr) = self.instance.as_array() {
|
||||
for i in 0..arr.len() {
|
||||
if !result.evaluated_indices.contains(&i) {
|
||||
let mut item_path = self.join_path(&i.to_string());
|
||||
if let Some(child_instance) = arr.get(i) {
|
||||
if let Some(obj) = child_instance.as_object() {
|
||||
if let Some(id_str) = obj.get("id").and_then(|v| v.as_str()) {
|
||||
item_path = self.join_path(id_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
result.errors.push(ValidationError {
|
||||
code: "STRICT_ITEM_VIOLATION".to_string(),
|
||||
message: format!("Unexpected item at index {}", i),
|
||||
path: format!("{}/{}", self.path, i),
|
||||
path: item_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ impl<'a> ValidationContext<'a> {
|
||||
result: &mut ValidationResult,
|
||||
) -> Result<bool, ValidationError> {
|
||||
let current = self.instance;
|
||||
if let Some(ref compiled_fmt) = self.schema.compiled_format {
|
||||
if let Some(compiled_fmt) = self.schema.compiled_format.get() {
|
||||
match compiled_fmt {
|
||||
crate::database::schema::CompiledFormat::Func(f) => {
|
||||
let should = if let Some(s) = current.as_str() {
|
||||
|
||||
@ -13,28 +13,37 @@ impl<'a> ValidationContext<'a> {
|
||||
) -> Result<bool, ValidationError> {
|
||||
let current = self.instance;
|
||||
if let Some(obj) = current.as_object() {
|
||||
// Entity Bound Implicit Type Validation
|
||||
if let Some(lookup_key) = self.schema.id.as_ref().or(self.schema.r#ref.as_ref()) {
|
||||
let base_type_name = lookup_key.split('.').next_back().unwrap_or("").to_string();
|
||||
if let Some(type_def) = self.db.types.get(&base_type_name)
|
||||
&& let Some(type_val) = obj.get("type")
|
||||
// Entity implicit type validation
|
||||
if let Some(schema_identifier) = self.schema.identifier() {
|
||||
// Kick in if the data object has a type field
|
||||
if let Some(type_val) = obj.get("type")
|
||||
&& let Some(type_str) = type_val.as_str()
|
||||
{
|
||||
if type_def.variations.contains(type_str) {
|
||||
// Ensure it passes strict mode
|
||||
result.evaluated_keys.insert("type".to_string());
|
||||
// Check if the identifier is a global type name
|
||||
if let Some(type_def) = self.db.types.get(&schema_identifier) {
|
||||
// Ensure the instance type is a variation of the global type
|
||||
if type_def.variations.contains(type_str) {
|
||||
// Ensure it passes strict mode
|
||||
result.evaluated_keys.insert("type".to_string());
|
||||
} else {
|
||||
result.errors.push(ValidationError {
|
||||
code: "CONST_VIOLATED".to_string(), // Aligning with original const override errors
|
||||
message: format!(
|
||||
"Type '{}' is not a valid descendant for this entity bound schema",
|
||||
type_str
|
||||
),
|
||||
path: self.join_path("type"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.errors.push(ValidationError {
|
||||
code: "CONST_VIOLATED".to_string(), // Aligning with original const override errors
|
||||
message: format!(
|
||||
"Type '{}' is not a valid descendant for this entity bound schema",
|
||||
type_str
|
||||
),
|
||||
path: format!("{}/type", self.path),
|
||||
});
|
||||
// Ad-Hoc schemas natively use strict schema discriminator strings instead of variation inheritance
|
||||
if type_str == schema_identifier.as_str() {
|
||||
result.evaluated_keys.insert("type".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(min) = self.schema.min_properties
|
||||
&& (obj.len() as f64) < min
|
||||
{
|
||||
@ -44,6 +53,7 @@ impl<'a> ValidationContext<'a> {
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(max) = self.schema.max_properties
|
||||
&& (obj.len() as f64) > max
|
||||
{
|
||||
@ -53,13 +63,14 @@ impl<'a> ValidationContext<'a> {
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref req) = self.schema.required {
|
||||
for field in req {
|
||||
if !obj.contains_key(field) {
|
||||
result.errors.push(ValidationError {
|
||||
code: "REQUIRED_FIELD_MISSING".to_string(),
|
||||
message: format!("Missing {}", field),
|
||||
path: format!("{}/{}", self.path, field),
|
||||
path: self.join_path(field),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -98,7 +109,7 @@ impl<'a> ValidationContext<'a> {
|
||||
}
|
||||
|
||||
if let Some(child_instance) = obj.get(key) {
|
||||
let new_path = format!("{}/{}", self.path, key);
|
||||
let new_path = self.join_path(key);
|
||||
let is_ref = sub_schema.r#ref.is_some();
|
||||
let next_extensible = if is_ref { false } else { self.extensible };
|
||||
|
||||
@ -114,10 +125,9 @@ impl<'a> ValidationContext<'a> {
|
||||
|
||||
// Entity Bound Implicit Type Interception
|
||||
if key == "type"
|
||||
&& let Some(lookup_key) = sub_schema.id.as_ref().or(sub_schema.r#ref.as_ref())
|
||||
&& let Some(schema_bound) = sub_schema.identifier()
|
||||
{
|
||||
let base_type_name = lookup_key.split('.').next_back().unwrap_or("").to_string();
|
||||
if let Some(type_def) = self.db.types.get(&base_type_name)
|
||||
if let Some(type_def) = self.db.types.get(&schema_bound)
|
||||
&& let Some(instance_type) = child_instance.as_str()
|
||||
&& type_def.variations.contains(instance_type)
|
||||
{
|
||||
@ -133,11 +143,11 @@ impl<'a> ValidationContext<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref compiled_pp) = self.schema.compiled_pattern_properties {
|
||||
if let Some(compiled_pp) = self.schema.compiled_pattern_properties.get() {
|
||||
for (compiled_re, sub_schema) in compiled_pp {
|
||||
for (key, child_instance) in obj {
|
||||
if compiled_re.0.is_match(key) {
|
||||
let new_path = format!("{}/{}", self.path, key);
|
||||
let new_path = self.join_path(key);
|
||||
let is_ref = sub_schema.r#ref.is_some();
|
||||
let next_extensible = if is_ref { false } else { self.extensible };
|
||||
|
||||
@ -165,7 +175,7 @@ impl<'a> ValidationContext<'a> {
|
||||
{
|
||||
locally_matched = true;
|
||||
}
|
||||
if !locally_matched && let Some(ref compiled_pp) = self.schema.compiled_pattern_properties
|
||||
if !locally_matched && let Some(compiled_pp) = self.schema.compiled_pattern_properties.get()
|
||||
{
|
||||
for (compiled_re, _) in compiled_pp {
|
||||
if compiled_re.0.is_match(key) {
|
||||
@ -176,7 +186,7 @@ impl<'a> ValidationContext<'a> {
|
||||
}
|
||||
|
||||
if !locally_matched {
|
||||
let new_path = format!("{}/{}", self.path, key);
|
||||
let new_path = self.join_path(key);
|
||||
let is_ref = additional_schema.r#ref.is_some();
|
||||
let next_extensible = if is_ref { false } else { self.extensible };
|
||||
|
||||
@ -197,7 +207,7 @@ impl<'a> ValidationContext<'a> {
|
||||
|
||||
if let Some(ref property_names) = self.schema.property_names {
|
||||
for key in obj.keys() {
|
||||
let _new_path = format!("{}/propertyNames/{}", self.path, key);
|
||||
let _new_path = self.join_path(&format!("propertyNames/{}", key));
|
||||
let val_str = Value::String(key.to_string());
|
||||
|
||||
let ctx = ValidationContext::new(
|
||||
|
||||
@ -28,7 +28,7 @@ impl<'a> ValidationContext<'a> {
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(ref compiled_re) = self.schema.compiled_pattern {
|
||||
if let Some(compiled_re) = self.schema.compiled_pattern.get() {
|
||||
if !compiled_re.0.is_match(s) {
|
||||
result.errors.push(ValidationError {
|
||||
code: "PATTERN_VIOLATED".to_string(),
|
||||
|
||||
54
t10.json
54
t10.json
@ -1,54 +0,0 @@
|
||||
[
|
||||
[
|
||||
"(SELECT jsonb_build_object(",
|
||||
" 'id', organization_1.id,",
|
||||
" 'type', CASE",
|
||||
" WHEN organization_1.type = 'person' THEN",
|
||||
" ((SELECT jsonb_build_object(",
|
||||
" 'age', person_3.age,",
|
||||
" 'archived', entity_5.archived,",
|
||||
" 'created_at', entity_5.created_at,",
|
||||
" 'first_name', person_3.first_name,",
|
||||
" 'id', entity_5.id,",
|
||||
" 'last_name', person_3.last_name,",
|
||||
" 'name', entity_5.name,",
|
||||
" 'type', entity_5.type",
|
||||
" )",
|
||||
" FROM agreego.person person_3",
|
||||
" JOIN agreego.organization organization_4 ON organization_4.id = person_3.id",
|
||||
" JOIN agreego.entity entity_5 ON entity_5.id = organization_4.id",
|
||||
" WHERE",
|
||||
" NOT entity_5.archived))",
|
||||
" WHEN organization_1.type = 'bot' THEN",
|
||||
" ((SELECT jsonb_build_object(",
|
||||
" 'archived', entity_8.archived,",
|
||||
" 'created_at', entity_8.created_at,",
|
||||
" 'id', entity_8.id,",
|
||||
" 'name', entity_8.name,",
|
||||
" 'token', bot_6.token,",
|
||||
" 'type', entity_8.type",
|
||||
" )",
|
||||
" FROM agreego.bot bot_6",
|
||||
" JOIN agreego.organization organization_7 ON organization_7.id = bot_6.id",
|
||||
" JOIN agreego.entity entity_8 ON entity_8.id = organization_7.id",
|
||||
" WHERE",
|
||||
" NOT entity_8.archived))",
|
||||
" WHEN organization_1.type = 'organization' THEN",
|
||||
" ((SELECT jsonb_build_object(",
|
||||
" 'archived', entity_10.archived,",
|
||||
" 'created_at', entity_10.created_at,",
|
||||
" 'id', entity_10.id,",
|
||||
" 'name', entity_10.name,",
|
||||
" 'type', entity_10.type",
|
||||
" )",
|
||||
" FROM agreego.organization organization_9",
|
||||
" JOIN agreego.entity entity_10 ON entity_10.id = organization_9.id",
|
||||
" WHERE",
|
||||
" NOT entity_10.archived))",
|
||||
" ELSE NULL END",
|
||||
")",
|
||||
"FROM agreego.organization organization_1",
|
||||
"JOIN agreego.entity entity_2 ON entity_2.id = organization_1.id",
|
||||
"WHERE NOT entity_2.archived)"
|
||||
]
|
||||
]
|
||||
164
t4.json
164
t4.json
@ -1,164 +0,0 @@
|
||||
[
|
||||
[
|
||||
"(SELECT jsonb_build_object(",
|
||||
" 'addresses',",
|
||||
" (SELECT COALESCE(jsonb_agg(jsonb_build_object(",
|
||||
" 'archived', entity_6.archived,",
|
||||
" 'created_at', entity_6.created_at,",
|
||||
" 'id', entity_6.id,",
|
||||
" 'is_primary', contact_4.is_primary,",
|
||||
" 'name', entity_6.name,",
|
||||
" 'target',",
|
||||
" (SELECT jsonb_build_object(",
|
||||
" 'archived', entity_8.archived,",
|
||||
" 'city', address_7.city,",
|
||||
" 'created_at', entity_8.created_at,",
|
||||
" 'id', entity_8.id,",
|
||||
" 'name', entity_8.name,",
|
||||
" 'type', entity_8.type",
|
||||
" )",
|
||||
" FROM agreego.address address_7",
|
||||
" JOIN agreego.entity entity_8 ON entity_8.id = address_7.id",
|
||||
" WHERE",
|
||||
" NOT entity_8.archived",
|
||||
" AND relationship_5.target_id = address_7.id),",
|
||||
" 'type', entity_6.type",
|
||||
" )), '[]'::jsonb)",
|
||||
" FROM agreego.contact contact_4",
|
||||
" JOIN agreego.relationship relationship_5 ON relationship_5.id = contact_4.id",
|
||||
" JOIN agreego.entity entity_6 ON entity_6.id = relationship_5.id",
|
||||
" WHERE",
|
||||
" NOT entity_6.archived",
|
||||
" AND contact_4.parent_id = entity_3.id),",
|
||||
" 'age', person_1.age,",
|
||||
" 'archived', entity_3.archived,",
|
||||
" 'contacts',",
|
||||
" (SELECT COALESCE(jsonb_agg(jsonb_build_object(",
|
||||
" 'archived', entity_11.archived,",
|
||||
" 'created_at', entity_11.created_at,",
|
||||
" 'id', entity_11.id,",
|
||||
" 'is_primary', contact_9.is_primary,",
|
||||
" 'name', entity_11.name,",
|
||||
" 'target', CASE",
|
||||
" WHEN entity_11.target_type = 'address' THEN",
|
||||
" ((SELECT jsonb_build_object(",
|
||||
" 'archived', entity_17.archived,",
|
||||
" 'city', address_16.city,",
|
||||
" 'created_at', entity_17.created_at,",
|
||||
" 'id', entity_17.id,",
|
||||
" 'name', entity_17.name,",
|
||||
" 'type', entity_17.type",
|
||||
" )",
|
||||
" FROM agreego.address address_16",
|
||||
" JOIN agreego.entity entity_17 ON entity_17.id = address_16.id",
|
||||
" WHERE",
|
||||
" NOT entity_17.archived",
|
||||
" AND relationship_10.target_id = address_16.id))",
|
||||
" WHEN entity_11.target_type = 'email_address' THEN",
|
||||
" ((SELECT jsonb_build_object(",
|
||||
" 'address', email_address_14.address,",
|
||||
" 'archived', entity_15.archived,",
|
||||
" 'created_at', entity_15.created_at,",
|
||||
" 'id', entity_15.id,",
|
||||
" 'name', entity_15.name,",
|
||||
" 'type', entity_15.type",
|
||||
" )",
|
||||
" FROM agreego.email_address email_address_14",
|
||||
" JOIN agreego.entity entity_15 ON entity_15.id = email_address_14.id",
|
||||
" WHERE",
|
||||
" NOT entity_15.archived",
|
||||
" AND relationship_10.target_id = email_address_14.id))",
|
||||
" WHEN entity_11.target_type = 'phone_number' THEN",
|
||||
" ((SELECT jsonb_build_object(",
|
||||
" 'archived', entity_13.archived,",
|
||||
" 'created_at', entity_13.created_at,",
|
||||
" 'id', entity_13.id,",
|
||||
" 'name', entity_13.name,",
|
||||
" 'number', phone_number_12.number,",
|
||||
" 'type', entity_13.type",
|
||||
" )",
|
||||
" FROM agreego.phone_number phone_number_12",
|
||||
" JOIN agreego.entity entity_13 ON entity_13.id = phone_number_12.id",
|
||||
" WHERE",
|
||||
" NOT entity_13.archived",
|
||||
" AND relationship_10.target_id = phone_number_12.id))",
|
||||
" ELSE NULL END,",
|
||||
" 'type', entity_11.type",
|
||||
" )), '[]'::jsonb)",
|
||||
" FROM agreego.contact contact_9",
|
||||
" JOIN agreego.relationship relationship_10 ON relationship_10.id = contact_9.id",
|
||||
" JOIN agreego.entity entity_11 ON entity_11.id = relationship_10.id",
|
||||
" WHERE",
|
||||
" NOT entity_11.archived",
|
||||
" AND contact_9.parent_id = entity_3.id),",
|
||||
" 'created_at', entity_3.created_at,",
|
||||
" 'email_addresses',",
|
||||
" (SELECT COALESCE(jsonb_agg(jsonb_build_object(",
|
||||
" 'archived', entity_20.archived,",
|
||||
" 'created_at', entity_20.created_at,",
|
||||
" 'id', entity_20.id,",
|
||||
" 'is_primary', contact_18.is_primary,",
|
||||
" 'name', entity_20.name,",
|
||||
" 'target',",
|
||||
" (SELECT jsonb_build_object(",
|
||||
" 'address', email_address_21.address,",
|
||||
" 'archived', entity_22.archived,",
|
||||
" 'created_at', entity_22.created_at,",
|
||||
" 'id', entity_22.id,",
|
||||
" 'name', entity_22.name,",
|
||||
" 'type', entity_22.type",
|
||||
" )",
|
||||
" FROM agreego.email_address email_address_21",
|
||||
" JOIN agreego.entity entity_22 ON entity_22.id = email_address_21.id",
|
||||
" WHERE",
|
||||
" NOT entity_22.archived",
|
||||
" AND relationship_19.target_id = email_address_21.id),",
|
||||
" 'type', entity_20.type",
|
||||
" )), '[]'::jsonb)",
|
||||
" FROM agreego.contact contact_18",
|
||||
" JOIN agreego.relationship relationship_19 ON relationship_19.id = contact_18.id",
|
||||
" JOIN agreego.entity entity_20 ON entity_20.id = relationship_19.id",
|
||||
" WHERE",
|
||||
" NOT entity_20.archived",
|
||||
" AND contact_18.parent_id = entity_3.id),",
|
||||
" 'first_name', person_1.first_name,",
|
||||
" 'id', entity_3.id,",
|
||||
" 'last_name', person_1.last_name,",
|
||||
" 'name', entity_3.name,",
|
||||
" 'phone_numbers',",
|
||||
" (SELECT COALESCE(jsonb_agg(jsonb_build_object(",
|
||||
" 'archived', entity_25.archived,",
|
||||
" 'created_at', entity_25.created_at,",
|
||||
" 'id', entity_25.id,",
|
||||
" 'is_primary', contact_23.is_primary,",
|
||||
" 'name', entity_25.name,",
|
||||
" 'target',",
|
||||
" (SELECT jsonb_build_object(",
|
||||
" 'archived', entity_27.archived,",
|
||||
" 'created_at', entity_27.created_at,",
|
||||
" 'id', entity_27.id,",
|
||||
" 'name', entity_27.name,",
|
||||
" 'number', phone_number_26.number,",
|
||||
" 'type', entity_27.type",
|
||||
" )",
|
||||
" FROM agreego.phone_number phone_number_26",
|
||||
" JOIN agreego.entity entity_27 ON entity_27.id = phone_number_26.id",
|
||||
" WHERE",
|
||||
" NOT entity_27.archived",
|
||||
" AND relationship_24.target_id = phone_number_26.id),",
|
||||
" 'type', entity_25.type",
|
||||
" )), '[]'::jsonb)",
|
||||
" FROM agreego.contact contact_23",
|
||||
" JOIN agreego.relationship relationship_24 ON relationship_24.id = contact_23.id",
|
||||
" JOIN agreego.entity entity_25 ON entity_25.id = relationship_24.id",
|
||||
" WHERE",
|
||||
" NOT entity_25.archived",
|
||||
" AND contact_23.parent_id = entity_3.id),",
|
||||
" 'type', entity_3.type",
|
||||
")",
|
||||
"FROM agreego.person person_1",
|
||||
"JOIN agreego.organization organization_2 ON organization_2.id = person_1.id",
|
||||
"JOIN agreego.entity entity_3 ON entity_3.id = organization_2.id",
|
||||
"WHERE NOT entity_3.archived)"
|
||||
]
|
||||
]
|
||||
Reference in New Issue
Block a user