Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 02e661d219 | |||
| f7163e2689 | |||
| 091007006d | |||
| 3d66a7fc3c | |||
| e1314496dd | |||
| 70a27b430d | |||
| e078b8a74b | |||
| c2c0e62c2d | |||
| ebb97b3509 | |||
| 5d18847f32 | |||
| 4a33e29628 | |||
| d8fc286e94 | |||
| 507dc6d780 | |||
| e340039a30 | |||
| 08768e3d42 | |||
| 6c9e6575ce | |||
| 5d11c4c92c | |||
| 25239d635b | |||
| 3bec6a6102 | |||
| 6444b300b3 | |||
| c529c8b8ea | |||
| 2f15ae3d41 | |||
| f8528aa85e | |||
| b6f383e700 | |||
| db5183930d | |||
| 6de75ba525 | |||
| 6632570712 | |||
| d4347072f2 | |||
| 290464adc1 | |||
| d6deaa0b0f | |||
| 6a275e1d90 |
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
|
||||||
|
|
||||||
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"rust-lang.rust-analyzer"
|
||||||
|
]
|
||||||
|
}
|
||||||
81
Cargo.lock
generated
81
Cargo.lock
generated
@ -55,6 +55,15 @@ version = "1.0.101"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ar_archive_writer"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
|
||||||
|
dependencies = [
|
||||||
|
"object",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@ -874,6 +883,7 @@ dependencies = [
|
|||||||
"regex-syntax",
|
"regex-syntax",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sqlparser",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"xxhash-rust",
|
"xxhash-rust",
|
||||||
@ -1040,6 +1050,15 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "object"
|
||||||
|
version = "0.37.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@ -1377,6 +1396,16 @@ dependencies = [
|
|||||||
"unarray",
|
"unarray",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psm"
|
||||||
|
version = "0.1.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
|
||||||
|
dependencies = [
|
||||||
|
"ar_archive_writer",
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@ -1442,6 +1471,26 @@ dependencies = [
|
|||||||
"rand_core",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "recursive"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e"
|
||||||
|
dependencies = [
|
||||||
|
"recursive-proc-macro-impl",
|
||||||
|
"stacker",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "recursive-proc-macro-impl"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@ -1669,12 +1718,35 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlparser"
|
||||||
|
version = "0.61.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbf5ea8d4d7c808e1af1cbabebca9a2abe603bcefc22294c5b95018d53200cb7"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"recursive",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stacker"
|
||||||
|
version = "0.1.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"psm",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stringprep"
|
name = "stringprep"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -2323,6 +2395,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
|
|||||||
@ -23,6 +23,7 @@ indexmap = { version = "2.13.0", features = ["serde"] }
|
|||||||
moka = { version = "0.12.14", features = ["sync"] }
|
moka = { version = "0.12.14", features = ["sync"] }
|
||||||
xxhash-rust = { version = "0.8.15", features = ["xxh64"] }
|
xxhash-rust = { version = "0.8.15", features = ["xxh64"] }
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
|
sqlparser = "0.61.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pgrx-tests = "0.16.1"
|
pgrx-tests = "0.16.1"
|
||||||
|
|||||||
58
GEMINI.md
58
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:
|
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.
|
* **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.
|
* **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
|
### 🎯 Goals
|
||||||
1. **Draft 2020-12 Compliance**: Attempt to adhere to the official JSON Schema Draft 2020-12 specification.
|
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.
|
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.
|
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.
|
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")
|
### 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:
|
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.
|
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).
|
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.
|
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
|
## 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.
|
The Validator provides strict, schema-driven evaluation for the "Punc" architecture.
|
||||||
|
|
||||||
### API Reference
|
### API Reference
|
||||||
* `jspg_setup(database jsonb) -> jsonb`: Loads and compiles the entire registry (types, enums, puncs, relations) atomically.
|
* `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.
|
||||||
* `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.
|
|
||||||
|
|
||||||
### Custom Features & Deviations
|
### 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.
|
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.
|
||||||
@ -43,7 +47,7 @@ JSPG implements specific extensions to the Draft 2020-12 standard to support the
|
|||||||
#### A. Polymorphism & Referencing (`$ref`, `$family`, and Native Types)
|
#### A. Polymorphism & Referencing (`$ref`, `$family`, and Native Types)
|
||||||
* **Native Type Discrimination (`variations`)**: Schemas defined inside a Postgres `type` are Entities. The validator securely and implicitly manages their `"type"` property. If an entity inherits from `user`, incoming JSON can safely define `{"type": "person"}` without errors, thanks to `compiled_variations` inheritance.
|
* **Native Type Discrimination (`variations`)**: Schemas defined inside a Postgres `type` are Entities. The validator securely and implicitly manages their `"type"` property. If an entity inherits from `user`, incoming JSON can safely define `{"type": "person"}` without errors, thanks to `compiled_variations` inheritance.
|
||||||
* **Structural Inheritance & Viral Infection (`$ref`)**: `$ref` is used exclusively for structural inheritance, *never* for union creation. A Punc request schema that `$ref`s an Entity virally inherits all physical database polymorphism rules for that target.
|
* **Structural Inheritance & Viral Infection (`$ref`)**: `$ref` is used exclusively for structural inheritance, *never* for union creation. A Punc request schema that `$ref`s an Entity virally inherits all physical database polymorphism rules for that target.
|
||||||
* **Shape Polymorphism (`$family`)**: Auto-expands polymorphic API lists based on an abstract Descendants Graph. If `{"$family": "widget"}` is used, JSPG evaluates the JSON against every schema that `$ref`s widget.
|
* **Shape Polymorphism (`$family`)**: Auto-expands polymorphic API lists based on an abstract **Descendants Graph**. If `{"$family": "widget"}` is used, the Validator dynamically identifies *every* schema in the registry that `$ref`s `widget` (e.g., `stock.widget`, `task.widget`) and evaluates the JSON against all of them.
|
||||||
* **Strict Matches & Depth Heuristic**: Polymorphic structures MUST match exactly **one** schema permutation. If multiple inherited struct permutations pass, JSPG applies the **Depth Heuristic Tie-Breaker**, selecting the candidate deepest in the inheritance tree.
|
* **Strict Matches & Depth Heuristic**: Polymorphic structures MUST match exactly **one** schema permutation. If multiple inherited struct permutations pass, JSPG applies the **Depth Heuristic Tie-Breaker**, selecting the candidate deepest in the inheritance tree.
|
||||||
|
|
||||||
#### B. Dot-Notation Schema Resolution & Database Mapping
|
#### B. Dot-Notation Schema Resolution & Database Mapping
|
||||||
@ -69,17 +73,22 @@ To simplify frontend form validation, format validators specifically for `uuid`,
|
|||||||
|
|
||||||
## 3. Merger
|
## 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
|
### 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.
|
* **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.
|
* **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.
|
* **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.
|
||||||
* **Hierarchical Table Inheritance**: The Punc system uses distributed table inheritance (e.g. `person` inherits `user` inherits `organization` inherits `entity`). The Merger splits the incoming JSON payload and performs atomic row updates across *all* relevant tables in the lineage map.
|
* **Hierarchical Table Inheritance**: The Punc system uses distributed table inheritance (e.g. `person` inherits `user` inherits `organization` inherits `entity`). The Merger splits the incoming JSON payload and performs atomic row updates across *all* relevant tables in the lineage map.
|
||||||
* **The Archive Paradigm**: Data is never deleted in the Punc system. The Merger securely enforces referential integrity by toggling the `archived` Boolean flag on the base `entity` table rather than issuing SQL `DELETE` commands.
|
* **The Archive Paradigm**: Data is never deleted in the Punc system. The Merger securely enforces referential integrity by toggling the `archived` Boolean flag on the base `entity` table rather than issuing SQL `DELETE` commands.
|
||||||
* **Change Tracking & Reactivity**: The Merger diffs the incoming JSON against the existing database row (utilizing static, `DashMap`-cached `lk_` SELECT string templates). Every detected change is recorded into the `agreego.change` audit table, tracking the user mapping. It then natively uses `pg_notify` to broadcast a completely flat row-level diff out to the Go WebSocket server for O(1) routing.
|
* **Change Tracking & Reactivity**: The Merger diffs the incoming JSON against the existing database row (utilizing static, `DashMap`-cached `lk_` SELECT string templates). Every detected change is recorded into the `agreego.change` audit table, tracking the user mapping. It then natively uses `pg_notify` to broadcast a completely flat row-level diff out to the Go WebSocket server for O(1) routing.
|
||||||
|
* **Flat Structural Beats (Unidirectional Flow)**: The Merger purposefully DOES NOT trace or hydrate outbound Foreign Keys or nested parent structures during writes. It emits completely flat, mathematically perfect structural deltas via `pg_notify` representing only the exact Postgres rows that changed. This guarantees the write-path remains O(1) lightning fast. It is the strict responsibility of the upstream Punc Framework (the Go `Speaker`) to intercept these flat beats, evaluate them against active Websocket Schema Topologies, and dynamically issue targeted `jspg_query` reads to hydrate the exact contextual subgraphs required by listening clients.
|
||||||
|
* **Pre-Order Notification Traversal**: To support proper topological hydration on the upstream Go Framework, the Merger decouples the `pg_notify` execution from the physical database write execution. The engine collects structural changes and explicitly fires `pg_notify` SQL statements in strict **Pre-Order** (Parent -> Relations -> Children). This guarantees that WebSocket clients receive the parent entity `Beat` prior to any nested child entities, ensuring stable unidirectional data flows without hydration race conditions.
|
||||||
* **Many-to-Many Graph Edge Management**: Operates seamlessly with the global `agreego.relationship` table, allowing the system to represent and merge arbitrary reified M:M relationships directionally between any two entities.
|
* **Many-to-Many Graph Edge Management**: Operates seamlessly with the global `agreego.relationship` table, allowing the system to represent and merge arbitrary reified M:M relationships directionally between any two entities.
|
||||||
* **Sparse Updates**: Empty JSON strings `""` are directly bound as explicit SQL `NULL` directives to clear data, whilst omitted (missing) properties skip UPDATE execution entirely, ensuring partial UI submissions do not wipe out sibling fields.
|
* **Sparse Updates**: Empty JSON strings `""` are directly bound as explicit SQL `NULL` directives to clear data, whilst omitted (missing) properties skip UPDATE execution entirely, ensuring partial UI submissions do not wipe out sibling fields.
|
||||||
* **Unified Return Structure**: To eliminate UI hydration race conditions and multi-user duplication, `jspg_merge` explicitly strips the response graph and returns only the root `{ "id": "uuid" }` (or an array of IDs for list insertions). External APIs can then explicitly call read APIs to fetch the resulting graph, while the UI relies 100% implicitly on the flat `pg_notify` pipeline for reactive state synchronization.
|
* **Unified Return Structure**: To eliminate UI hydration race conditions and multi-user duplication, `jspg_merge` explicitly strips the response graph and returns only the root `{ "id": "uuid" }` (or an array of IDs for list insertions). External APIs can then explicitly call read APIs to fetch the resulting graph, while the UI relies 100% implicitly on the flat `pg_notify` pipeline for reactive state synchronization.
|
||||||
@ -89,7 +98,10 @@ The Merger provides an automated, high-performance graph synchronization engine
|
|||||||
|
|
||||||
## 4. Queryer
|
## 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
|
### Core Features
|
||||||
|
|
||||||
@ -101,19 +113,16 @@ The Queryer transforms Postgres into a pre-compiled Semantic Query Engine via th
|
|||||||
* **Array Inclusion**: `{"$in": [values]}`, `{"$nin": [values]}` use native `jsonb_array_elements_text()` bindings to enforce `IN` and `NOT IN` logic without runtime SQL injection risks.
|
* **Array Inclusion**: `{"$in": [values]}`, `{"$nin": [values]}` use native `jsonb_array_elements_text()` bindings to enforce `IN` and `NOT IN` logic without runtime SQL injection risks.
|
||||||
* **Text Matching (ILIKE)**: Evaluates `$eq` or `$ne` against string fields containing the `%` character natively into Postgres `ILIKE` and `NOT ILIKE` partial substring matches.
|
* **Text Matching (ILIKE)**: Evaluates `$eq` or `$ne` against string fields containing the `%` character natively into Postgres `ILIKE` and `NOT ILIKE` partial substring matches.
|
||||||
* **Type Casting**: Safely resolves dynamic combinations by casting values instantly into the physical database types mapped in the schema (e.g. parsing `uuid` bindings to `::uuid`, formatting DateTimes to `::timestamptz`, and numbers to `::numeric`).
|
* **Type Casting**: Safely resolves dynamic combinations by casting values instantly into the physical database types mapped in the schema (e.g. parsing `uuid` bindings to `::uuid`, formatting DateTimes to `::timestamptz`, and numbers to `::numeric`).
|
||||||
### 4. The Stem Engine
|
* **Polymorphic SQL Generation (`$family`)**: Compiles `$family` properties by analyzing the **Physical Database Variations**, *not* the schema descendants.
|
||||||
|
* **The Dot Convention**: When a schema requests `$family: "target.schema"`, the compiler extracts the base type (e.g. `schema`) and looks up its Physical Table definition.
|
||||||
|
* **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.
|
||||||
|
|
||||||
Rather than over-fetching heavy Entity payloads and trimming them, Punc Framework Websockets depend on isolated subgraphs defined as **Stems**.
|
### Ad-Hoc Schema Promotion
|
||||||
A `Stem` is **not a JSON Pointer** or a physical path string (like `/properties/contacts/items/phone_number`). It is simply a declaration of an **Entity Type boundary** that exists somewhere within the compiled JSON Schema graph.
|
|
||||||
|
|
||||||
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?"
|
|
||||||
|
|
||||||
* **Initialization:** During startup (`jspg_stems()`), the database crawls all Schemas and maps out every physical Entity Type it references. It builds a flat dictionary of `Schema ID -> [Entity Types]` (e.g. `with_contacts.person -> ["person", "contact", "phone_number", "email_address"]`).
|
|
||||||
* **Relationship Path Squashing:** When calculating nested string paths structurally to discover these boundaries, JSPG intentionally **omits** properties natively named `target` or `source` if they belong to a native database `relationship` table override. This ensures paths like `phone_numbers/contact/target` correctly register their beat resolution pattern as `phone_numbers/contact/phone_number`.
|
|
||||||
* **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
|
## 5. Testing & Execution Architecture
|
||||||
|
|
||||||
@ -127,7 +136,8 @@ To solve this, JSPG introduces the `DatabaseExecutor` trait inside `src/database
|
|||||||
### Universal Test Harness (`src/tests/`)
|
### Universal Test Harness (`src/tests/`)
|
||||||
JSPG abandons the standard `cargo pgrx test` model in favor of native OS testing for a >1000x speed increase (`~0.05s` execution).
|
JSPG abandons the standard `cargo pgrx test` model in favor of native OS testing for a >1000x speed increase (`~0.05s` execution).
|
||||||
|
|
||||||
1. **JSON Fixtures**: All core interactions are defined abstractly as JSON arrays in `fixtures/`. Each file contains suites of `TestCase` objects with an `action` flag (`validate`, `merge`, `query`).
|
1. **JSON Fixtures**: All core interactions are defined abstractly as JSON arrays in `fixtures/`. Each file contains suites of `TestCase` objects with an `action` flag (`compile`, `validate`, `merge`, `query`).
|
||||||
2. **`build.rs` Generator**: The build script traverses the JSON fixtures, extracts their structural identities, and generates standard `#[test]` blocks into `src/tests/fixtures.rs`.
|
2. **`build.rs` Generator**: The build script traverses the JSON fixtures, extracts their structural identities, and generates standard `#[test]` blocks into `src/tests/fixtures.rs`.
|
||||||
3. **Modular Test Dispatcher**: The `src/tests/types/` module deserializes the abstract JSON test payloads into `Suite`, `Case`, and `Expect` data structures.
|
3. **Modular Test Dispatcher**: The `src/tests/types/` module deserializes the abstract JSON test payloads into `Suite`, `Case`, and `Expect` data structures.
|
||||||
4. **Unit Context Execution**: When `cargo test` executes, the `Runner` feeds the JSON payloads directly into `case.execute(db)`. Because the tests run natively inside the module via `#cfg(test)`, the Rust compiler globally erases `pgrx` C-linkage, instantiates the `MockExecutor`, and allows for pure structural evaluation of complex database logic completely in memory.
|
* The `compile` action natively asserts the exact output shape of `jspg_stems`, allowing structural and relationship mapping logic to be tested purely through JSON without writing brute-force manual tests in Rust.
|
||||||
|
4. **Unit Context Execution**: When `cargo test` executes, the runner iterates the JSON payloads. Because the tests run natively inside the module via `#cfg(test)`, the Rust compiler globally erases `pgrx` C-linkage, instantiates the `MockExecutor`, and allows for pure structural evaluation of complex database logic completely in memory in parallel.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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,
|
||||||
|
}
|
||||||
@ -24,7 +24,9 @@ impl DatabaseExecutor for SpiExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pgrx::PgTryBuilder::new(|| {
|
||||||
Spi::connect(|client| {
|
Spi::connect(|client| {
|
||||||
|
pgrx::notice!("JSPG_SQL: {}", sql);
|
||||||
match client.select(sql, Some(args_with_oid.len() as i64), &args_with_oid) {
|
match client.select(sql, Some(args_with_oid.len() as i64), &args_with_oid) {
|
||||||
Ok(tup_table) => {
|
Ok(tup_table) => {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
@ -38,6 +40,12 @@ impl DatabaseExecutor for SpiExecutor {
|
|||||||
Err(e) => Err(format!("SPI Query Fetch Failure: {}", e)),
|
Err(e) => Err(format!("SPI Query Fetch Failure: {}", e)),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
.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> {
|
fn execute(&self, sql: &str, args: Option<&[Value]>) -> Result<(), String> {
|
||||||
@ -52,12 +60,20 @@ impl DatabaseExecutor for SpiExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pgrx::PgTryBuilder::new(|| {
|
||||||
Spi::connect_mut(|client| {
|
Spi::connect_mut(|client| {
|
||||||
|
pgrx::notice!("JSPG_SQL: {}", sql);
|
||||||
match client.update(sql, Some(args_with_oid.len() as i64), &args_with_oid) {
|
match client.update(sql, Some(args_with_oid.len() as i64), &args_with_oid) {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(e) => Err(format!("SPI Execution Failure: {}", e)),
|
Err(e) => Err(format!("SPI Execution Failure: {}", e)),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
.catch_others(|cause| {
|
||||||
|
pgrx::warning!("JSPG Caught Native Postgres Error: {:?}", cause);
|
||||||
|
Err(format!("{:?}", cause))
|
||||||
|
})
|
||||||
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auth_user_id(&self) -> Result<String, String> {
|
fn auth_user_id(&self) -> Result<String, String> {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
pub mod edge;
|
||||||
pub mod r#enum;
|
pub mod r#enum;
|
||||||
pub mod executors;
|
pub mod executors;
|
||||||
pub mod formats;
|
pub mod formats;
|
||||||
@ -18,14 +19,11 @@ use executors::pgrx::SpiExecutor;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use executors::mock::MockExecutor;
|
use executors::mock::MockExecutor;
|
||||||
|
|
||||||
pub mod stem;
|
|
||||||
use punc::Punc;
|
use punc::Punc;
|
||||||
use relation::Relation;
|
use relation::Relation;
|
||||||
use schema::Schema;
|
use schema::Schema;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
|
||||||
use stem::Stem;
|
|
||||||
use r#type::Type;
|
use r#type::Type;
|
||||||
|
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
@ -34,8 +32,6 @@ pub struct Database {
|
|||||||
pub puncs: HashMap<String, Punc>,
|
pub puncs: HashMap<String, Punc>,
|
||||||
pub relations: HashMap<String, Relation>,
|
pub relations: HashMap<String, Relation>,
|
||||||
pub schemas: HashMap<String, Schema>,
|
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 descendants: HashMap<String, Vec<String>>,
|
||||||
pub depths: HashMap<String, usize>,
|
pub depths: HashMap<String, usize>,
|
||||||
pub executor: Box<dyn DatabaseExecutor + Send + Sync>,
|
pub executor: Box<dyn DatabaseExecutor + Send + Sync>,
|
||||||
@ -49,7 +45,6 @@ impl Database {
|
|||||||
relations: HashMap::new(),
|
relations: HashMap::new(),
|
||||||
puncs: HashMap::new(),
|
puncs: HashMap::new(),
|
||||||
schemas: HashMap::new(),
|
schemas: HashMap::new(),
|
||||||
stems: HashMap::new(),
|
|
||||||
descendants: HashMap::new(),
|
descendants: HashMap::new(),
|
||||||
depths: HashMap::new(),
|
depths: HashMap::new(),
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
@ -78,9 +73,24 @@ impl Database {
|
|||||||
for item in arr {
|
for item in arr {
|
||||||
match serde_json::from_value::<Relation>(item.clone()) {
|
match serde_json::from_value::<Relation>(item.clone()) {
|
||||||
Ok(def) => {
|
Ok(def) => {
|
||||||
|
if db.types.contains_key(&def.source_type)
|
||||||
|
&& db.types.contains_key(&def.destination_type)
|
||||||
|
{
|
||||||
db.relations.insert(def.constraint.clone(), def);
|
db.relations.insert(def.constraint.clone(), def);
|
||||||
}
|
}
|
||||||
Err(e) => println!("DATABASE RELATION PARSE FAILED: {:?}", e),
|
}
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -137,40 +147,68 @@ impl Database {
|
|||||||
self.executor.timestamp()
|
self.executor.timestamp()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Organizes the graph of the database, compiling regex, format functions, and caching relationships.
|
|
||||||
pub fn compile(&mut self) -> Result<(), crate::drop::Drop> {
|
pub fn compile(&mut self) -> Result<(), crate::drop::Drop> {
|
||||||
self.collect_schemas();
|
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_depths();
|
||||||
self.collect_descendants();
|
self.collect_descendants();
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_schemas(&mut self) {
|
fn collect_schemas(&mut self) -> Result<(), String> {
|
||||||
let mut to_insert = Vec::new();
|
let mut to_insert = Vec::new();
|
||||||
|
|
||||||
// Pass 1: Extract all Schemas structurally off top level definitions into the master registry.
|
// 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 type_def in self.types.values() {
|
||||||
for mut schema in type_def.schemas.clone() {
|
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 punc_def in self.puncs.values() {
|
||||||
for mut schema in punc_def.schemas.clone() {
|
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 enum_def in self.enums.values() {
|
||||||
for mut schema in enum_def.schemas.clone() {
|
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 {
|
for (id, schema) in to_insert {
|
||||||
self.schemas.insert(id, schema);
|
self.schemas.insert(id, schema);
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_depths(&mut self) {
|
fn collect_depths(&mut self) {
|
||||||
@ -228,8 +266,8 @@ impl Database {
|
|||||||
|
|
||||||
fn collect_descendants_recursively(
|
fn collect_descendants_recursively(
|
||||||
target: &str,
|
target: &str,
|
||||||
direct_refs: &HashMap<String, Vec<String>>,
|
direct_refs: &std::collections::HashMap<String, Vec<String>>,
|
||||||
descendants: &mut HashSet<String>,
|
descendants: &mut std::collections::HashSet<String>,
|
||||||
) {
|
) {
|
||||||
if let Some(children) = direct_refs.get(target) {
|
if let Some(children) = direct_refs.get(target) {
|
||||||
for child in children {
|
for child in children {
|
||||||
@ -239,218 +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,
|
|
||||||
&mut inner_map,
|
|
||||||
&mut errors,
|
|
||||||
);
|
|
||||||
if !inner_map.is_empty() {
|
|
||||||
println!("SCHEMA: {} STEMS: {:?}", schema_id, inner_map.keys());
|
|
||||||
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,
|
|
||||||
mut current_path: String,
|
|
||||||
parent_type: Option<String>,
|
|
||||||
property_name: Option<String>,
|
|
||||||
inner_map: &mut HashMap<String, Arc<Stem>>,
|
|
||||||
errors: &mut Vec<crate::drop::Error>,
|
|
||||||
) {
|
|
||||||
let mut is_entity = false;
|
|
||||||
let mut entity_type = String::new();
|
|
||||||
|
|
||||||
let mut examine_id = None;
|
|
||||||
if let Some(ref r) = schema.obj.r#ref {
|
|
||||||
examine_id = Some(r.clone());
|
|
||||||
} else if let Some(ref id) = schema.obj.id {
|
|
||||||
examine_id = Some(id.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(target) = examine_id {
|
|
||||||
let parts: Vec<&str> = target.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
for rel in db.relations.values() {
|
|
||||||
if (rel.source_type == *pt && rel.destination_type == entity_type)
|
|
||||||
|| (rel.source_type == entity_type && rel.destination_type == *pt)
|
|
||||||
{
|
|
||||||
if rel.source_columns.contains(&expected_col) {
|
|
||||||
relation_col = Some(expected_col.clone());
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
relation_col = Some(expected_col);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let stem = Stem {
|
|
||||||
r#type: entity_type.clone(),
|
|
||||||
relation: relation_col,
|
|
||||||
schema: Arc::new(schema.clone()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut branch_path = current_path.clone();
|
|
||||||
if !current_path.is_empty() {
|
|
||||||
branch_path = format!("{}/{}", current_path, entity_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
if inner_map.contains_key(&branch_path) {
|
|
||||||
errors.push(crate::drop::Error {
|
|
||||||
code: "STEM_COLLISION".to_string(),
|
|
||||||
message: format!("The stem path `{}` resolves to multiple Entity boundaries. This usually occurs during un-wrapped $family or oneOf polymorphic schemas where multiple Entities are directly assigned to the same property. To fix this, encapsulate the polymorphic branch.", branch_path),
|
|
||||||
details: crate::drop::ErrorDetails {
|
|
||||||
path: root_schema_id.to_string(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
inner_map.insert(branch_path.clone(), Arc::new(stem));
|
|
||||||
|
|
||||||
// Update current_path for structural children
|
|
||||||
current_path = branch_path;
|
|
||||||
}
|
|
||||||
|
|
||||||
let next_parent = if is_entity {
|
|
||||||
Some(entity_type.clone())
|
|
||||||
} else {
|
|
||||||
parent_type.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Properties branch
|
|
||||||
if let Some(props) = &schema.obj.properties {
|
|
||||||
for (k, v) in props {
|
|
||||||
// Bypass target and source properties if we are in a relationship
|
|
||||||
if let Some(parent_str) = &next_parent {
|
|
||||||
if let Some(pt) = db.types.get(parent_str) {
|
|
||||||
if pt.relationship && (k == "target" || k == "source") {
|
|
||||||
Self::discover_stems(
|
|
||||||
db,
|
|
||||||
root_schema_id,
|
|
||||||
v,
|
|
||||||
current_path.clone(),
|
|
||||||
next_parent.clone(),
|
|
||||||
Some(k.clone()),
|
|
||||||
inner_map,
|
|
||||||
errors,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()),
|
|
||||||
inner_map,
|
|
||||||
errors,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Array Item branch
|
|
||||||
if let Some(items) = &schema.obj.items {
|
|
||||||
Self::discover_stems(
|
|
||||||
db,
|
|
||||||
root_schema_id,
|
|
||||||
items,
|
|
||||||
current_path.clone(),
|
|
||||||
next_parent.clone(),
|
|
||||||
property_name.clone(),
|
|
||||||
inner_map,
|
|
||||||
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(),
|
|
||||||
inner_map,
|
|
||||||
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(),
|
|
||||||
inner_map,
|
|
||||||
errors,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,29 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
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.
|
// Schema mirrors the Go Punc Generator's schema struct for consistency.
|
||||||
// It is an order-preserving representation of a JSON Schema.
|
// It is an order-preserving representation of a JSON Schema.
|
||||||
|
|
||||||
pub fn deserialize_some<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
|
pub fn deserialize_some<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
|
||||||
where
|
where
|
||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
@ -13,125 +32,182 @@ where
|
|||||||
let v = Value::deserialize(deserializer)?;
|
let v = Value::deserialize(deserializer)?;
|
||||||
Ok(Some(v))
|
Ok(Some(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct SchemaObject {
|
pub struct SchemaObject {
|
||||||
// Core Schema Keywords
|
// Core Schema Keywords
|
||||||
#[serde(rename = "$id")]
|
#[serde(rename = "$id")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(rename = "$ref")]
|
#[serde(rename = "$ref")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub r#ref: Option<String>,
|
pub r#ref: Option<String>,
|
||||||
/*
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
Note: The `Ref` field in the Go struct is a pointer populated by the linker.
|
|
||||||
In Rust, we might handle this differently (e.g., separate lookup or Rc/Arc),
|
|
||||||
so we omit the direct recursive `Ref` field for now and rely on `ref_string`.
|
|
||||||
*/
|
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
#[serde(default)] // Allow missing type
|
#[serde(default)] // Allow missing type
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub type_: Option<SchemaTypeOrArray>, // Handles string or array of strings
|
pub type_: Option<SchemaTypeOrArray>, // Handles string or array of strings
|
||||||
|
|
||||||
// Object Keywords
|
// Object Keywords
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub properties: Option<BTreeMap<String, Arc<Schema>>>,
|
pub properties: Option<BTreeMap<String, Arc<Schema>>>,
|
||||||
#[serde(rename = "patternProperties")]
|
#[serde(rename = "patternProperties")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub pattern_properties: Option<BTreeMap<String, Arc<Schema>>>,
|
pub pattern_properties: Option<BTreeMap<String, Arc<Schema>>>,
|
||||||
#[serde(rename = "additionalProperties")]
|
#[serde(rename = "additionalProperties")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub additional_properties: Option<Arc<Schema>>,
|
pub additional_properties: Option<Arc<Schema>>,
|
||||||
#[serde(rename = "$family")]
|
#[serde(rename = "$family")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub family: Option<String>,
|
pub family: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub required: Option<Vec<String>>,
|
pub required: Option<Vec<String>>,
|
||||||
|
|
||||||
// dependencies can be schema dependencies or property dependencies
|
// dependencies can be schema dependencies or property dependencies
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub dependencies: Option<BTreeMap<String, Dependency>>,
|
pub dependencies: Option<BTreeMap<String, Dependency>>,
|
||||||
|
|
||||||
// Array Keywords
|
// Array Keywords
|
||||||
#[serde(rename = "items")]
|
#[serde(rename = "items")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub items: Option<Arc<Schema>>,
|
pub items: Option<Arc<Schema>>,
|
||||||
#[serde(rename = "prefixItems")]
|
#[serde(rename = "prefixItems")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub prefix_items: Option<Vec<Arc<Schema>>>,
|
pub prefix_items: Option<Vec<Arc<Schema>>>,
|
||||||
|
|
||||||
// String Validation
|
// String Validation
|
||||||
#[serde(rename = "minLength")]
|
#[serde(rename = "minLength")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub min_length: Option<f64>,
|
pub min_length: Option<f64>,
|
||||||
#[serde(rename = "maxLength")]
|
#[serde(rename = "maxLength")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub max_length: Option<f64>,
|
pub max_length: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub pattern: Option<String>,
|
pub pattern: Option<String>,
|
||||||
|
|
||||||
// Array Validation
|
// Array Validation
|
||||||
#[serde(rename = "minItems")]
|
#[serde(rename = "minItems")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub min_items: Option<f64>,
|
pub min_items: Option<f64>,
|
||||||
#[serde(rename = "maxItems")]
|
#[serde(rename = "maxItems")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub max_items: Option<f64>,
|
pub max_items: Option<f64>,
|
||||||
#[serde(rename = "uniqueItems")]
|
#[serde(rename = "uniqueItems")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub unique_items: Option<bool>,
|
pub unique_items: Option<bool>,
|
||||||
#[serde(rename = "contains")]
|
#[serde(rename = "contains")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub contains: Option<Arc<Schema>>,
|
pub contains: Option<Arc<Schema>>,
|
||||||
#[serde(rename = "minContains")]
|
#[serde(rename = "minContains")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub min_contains: Option<f64>,
|
pub min_contains: Option<f64>,
|
||||||
#[serde(rename = "maxContains")]
|
#[serde(rename = "maxContains")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub max_contains: Option<f64>,
|
pub max_contains: Option<f64>,
|
||||||
|
|
||||||
// Object Validation
|
// Object Validation
|
||||||
#[serde(rename = "minProperties")]
|
#[serde(rename = "minProperties")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub min_properties: Option<f64>,
|
pub min_properties: Option<f64>,
|
||||||
#[serde(rename = "maxProperties")]
|
#[serde(rename = "maxProperties")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub max_properties: Option<f64>,
|
pub max_properties: Option<f64>,
|
||||||
#[serde(rename = "propertyNames")]
|
#[serde(rename = "propertyNames")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub property_names: Option<Arc<Schema>>,
|
pub property_names: Option<Arc<Schema>>,
|
||||||
|
|
||||||
// Numeric Validation
|
// Numeric Validation
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub format: Option<String>,
|
pub format: Option<String>,
|
||||||
#[serde(rename = "enum")]
|
#[serde(rename = "enum")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub enum_: Option<Vec<Value>>, // `enum` is a reserved keyword in Rust
|
pub enum_: Option<Vec<Value>>, // `enum` is a reserved keyword in Rust
|
||||||
#[serde(
|
#[serde(
|
||||||
default,
|
default,
|
||||||
rename = "const",
|
rename = "const",
|
||||||
deserialize_with = "crate::database::schema::deserialize_some"
|
deserialize_with = "crate::database::schema::deserialize_some"
|
||||||
)]
|
)]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub const_: Option<Value>,
|
pub const_: Option<Value>,
|
||||||
|
|
||||||
// Numeric Validation
|
// Numeric Validation
|
||||||
#[serde(rename = "multipleOf")]
|
#[serde(rename = "multipleOf")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub multiple_of: Option<f64>,
|
pub multiple_of: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub minimum: Option<f64>,
|
pub minimum: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub maximum: Option<f64>,
|
pub maximum: Option<f64>,
|
||||||
#[serde(rename = "exclusiveMinimum")]
|
#[serde(rename = "exclusiveMinimum")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub exclusive_minimum: Option<f64>,
|
pub exclusive_minimum: Option<f64>,
|
||||||
#[serde(rename = "exclusiveMaximum")]
|
#[serde(rename = "exclusiveMaximum")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub exclusive_maximum: Option<f64>,
|
pub exclusive_maximum: Option<f64>,
|
||||||
|
|
||||||
// Combining Keywords
|
// Combining Keywords
|
||||||
#[serde(rename = "allOf")]
|
#[serde(rename = "allOf")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub all_of: Option<Vec<Arc<Schema>>>,
|
pub all_of: Option<Vec<Arc<Schema>>>,
|
||||||
#[serde(rename = "oneOf")]
|
#[serde(rename = "oneOf")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub one_of: Option<Vec<Arc<Schema>>>,
|
pub one_of: Option<Vec<Arc<Schema>>>,
|
||||||
#[serde(rename = "not")]
|
#[serde(rename = "not")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub not: Option<Arc<Schema>>,
|
pub not: Option<Arc<Schema>>,
|
||||||
#[serde(rename = "if")]
|
#[serde(rename = "if")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub if_: Option<Arc<Schema>>,
|
pub if_: Option<Arc<Schema>>,
|
||||||
#[serde(rename = "then")]
|
#[serde(rename = "then")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub then_: Option<Arc<Schema>>,
|
pub then_: Option<Arc<Schema>>,
|
||||||
#[serde(rename = "else")]
|
#[serde(rename = "else")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub else_: Option<Arc<Schema>>,
|
pub else_: Option<Arc<Schema>>,
|
||||||
|
|
||||||
// Custom Vocabularies
|
// Custom Vocabularies
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub form: Option<Vec<String>>,
|
pub form: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub display: Option<Vec<String>>,
|
pub display: Option<Vec<String>>,
|
||||||
#[serde(rename = "enumNames")]
|
#[serde(rename = "enumNames")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub enum_names: Option<Vec<String>>,
|
pub enum_names: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub control: Option<String>,
|
pub control: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub actions: Option<BTreeMap<String, Action>>,
|
pub actions: Option<BTreeMap<String, Action>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub computer: Option<String>,
|
pub computer: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub extensible: Option<bool>,
|
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)]
|
#[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)]
|
#[serde(skip)]
|
||||||
pub compiled_pattern: Option<CompiledRegex>,
|
pub compiled_format: OnceLock<CompiledFormat>,
|
||||||
#[serde(skip)]
|
#[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
|
/// Represents a compiled format validator
|
||||||
@ -175,19 +251,37 @@ impl std::ops::DerefMut for Schema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Schema {
|
impl Schema {
|
||||||
pub fn compile_internals(&mut self) {
|
pub fn compile(
|
||||||
self.map_children(|child| child.compile_internals());
|
&self,
|
||||||
|
db: &crate::database::Database,
|
||||||
if let Some(format_str) = &self.obj.format
|
visited: &mut std::collections::HashSet<String>,
|
||||||
&& let Some(fmt) = crate::database::formats::FORMATS.get(format_str.as_str())
|
) {
|
||||||
{
|
if self.obj.compiled_properties.get().is_some() {
|
||||||
self.obj.compiled_format = Some(crate::database::schema::CompiledFormat::Func(fmt.func));
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(pattern_str) = &self.obj.pattern
|
if let Some(id) = &self.obj.id {
|
||||||
&& let Ok(re) = regex::Regex::new(pattern_str)
|
if !visited.insert(id.clone()) {
|
||||||
{
|
return; // Break cyclical resolution
|
||||||
self.obj.compiled_pattern = Some(crate::database::schema::CompiledRegex(re));
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if let Some(pattern_props) = &self.obj.pattern_properties {
|
||||||
@ -198,73 +292,354 @@ impl Schema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !compiled.is_empty() {
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn harvest(&mut self, to_insert: &mut Vec<(String, Schema)>) {
|
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 {
|
if let Some(id) = &self.obj.id {
|
||||||
|
visited.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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()));
|
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")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn map_children<F>(&mut self, mut f: F)
|
// Is this schema an inline ad-hoc composition?
|
||||||
where
|
// Meaning it has a tracking context, lacks an explicit $id, but extends an Entity ref with explicit properties!
|
||||||
F: FnMut(&mut Schema),
|
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 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 {
|
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();
|
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);
|
*v = Arc::new(inner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(pattern_props) = &mut self.obj.pattern_properties {
|
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();
|
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);
|
*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() {
|
for v in arr.iter_mut() {
|
||||||
let mut inner = (**v).clone();
|
let mut inner = (**v).clone();
|
||||||
f(&mut inner);
|
inner.collect_schemas(origin_path.clone(), to_insert)?;
|
||||||
*v = Arc::new(inner);
|
*v = Arc::new(inner);
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(arr) = &mut self.obj.prefix_items {
|
if let Some(arr) = &mut self.obj.prefix_items { map_arr(arr)?; }
|
||||||
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.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 {
|
if let Some(v) = opt {
|
||||||
let mut inner = (**v).clone();
|
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);
|
*v = Arc::new(inner);
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
};
|
};
|
||||||
|
|
||||||
map_opt(&mut self.obj.additional_properties);
|
map_opt(&mut self.obj.additional_properties, false)?;
|
||||||
map_opt(&mut self.obj.items);
|
|
||||||
map_opt(&mut self.obj.contains);
|
// `items` absolutely must inherit the EXACT property path assigned to the Array wrapper!
|
||||||
map_opt(&mut self.obj.property_names);
|
// This allows nested Arrays enclosing bare Entity structs to correctly register as the boundary mapping.
|
||||||
map_opt(&mut self.obj.not);
|
map_opt(&mut self.obj.items, true)?;
|
||||||
map_opt(&mut self.obj.if_);
|
|
||||||
map_opt(&mut self.obj.then_);
|
map_opt(&mut self.obj.not, false)?;
|
||||||
map_opt(&mut self.obj.else_);
|
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(id) = &self.obj.id {
|
||||||
|
parent_type_name = Some(id.split('.').next_back().unwrap_or("").to_string());
|
||||||
|
} else if let Some(ref_id) = &self.obj.r#ref {
|
||||||
|
parent_type_name = Some(ref_id.split('.').next_back().unwrap_or("").to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
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.r#ref.as_ref() {
|
||||||
|
child_type_name = Some(ref_id.split('.').next_back().unwrap_or("").to_string());
|
||||||
|
} else if let Some(arr) = &target_schema.obj.one_of {
|
||||||
|
if let Some(first) = arr.first() {
|
||||||
|
if let Some(ref_id) = first.obj.id.as_ref().or(first.obj.r#ref.as_ref()) {
|
||||||
|
child_type_name = Some(ref_id.split('.').next_back().unwrap_or("").to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
impl<'de> Deserialize<'de> for Schema {
|
||||||
@ -331,7 +706,9 @@ pub enum SchemaTypeOrArray {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Action {
|
pub struct Action {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub navigate: Option<String>,
|
pub navigate: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub punc: Option<String>,
|
pub punc: Option<String>,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@ -1,17 +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>,
|
|
||||||
|
|
||||||
// The actual database schema node mapping for
|
|
||||||
// O(1) jump table execution for queryer.
|
|
||||||
//
|
|
||||||
// Automatically skipped from `jspg_stems()` JSON payload output.
|
|
||||||
#[serde(skip)]
|
|
||||||
pub schema: Arc<Schema>,
|
|
||||||
}
|
|
||||||
@ -15,6 +15,8 @@ pub struct Type {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub historical: bool,
|
pub historical: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub notify: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub sensitive: bool,
|
pub sensitive: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ownable: bool,
|
pub ownable: bool,
|
||||||
|
|||||||
@ -67,6 +67,10 @@ pub struct Error {
|
|||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct ErrorDetails {
|
pub struct ErrorDetails {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
// Extensions can be added here (package, cause, etc)
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
// For now, validator only provides path
|
pub cause: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub schema: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,79 +0,0 @@
|
|||||||
# Entity Engine (jspg)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document outlines the architecture for moving the complex, CPU-bound row merging (`merge_entity`) and dynamic querying (`query_entity`) functionality out of PL/pgSQL and directly into the Rust-based `jspg` extension.
|
|
||||||
|
|
||||||
By treating the `jspg` schema registry as the absolute Single Source of Truth, we can leverage Rust and the Postgres query planner (via SPI) to achieve near O(1) execution planning for deeply nested reads, complex relational writes, and partial hydration beats.
|
|
||||||
|
|
||||||
## The Problem
|
|
||||||
|
|
||||||
Historically, `agreego.merge_entity` (PL/pgSQL) handled nested writes by segmenting JSON, resolving types, searching hierarchies, and dynamically concatenating `INSERT`/`UPDATE` statements. `agreego.query_entity` was conceived to do the same for reads (handling base security, inheritance JOINs, and filtering automatically).
|
|
||||||
|
|
||||||
However, this design hits three major limitations:
|
|
||||||
1. **CPU Bound Operations**: PL/pgSQL is comparatively slow at complex string concatenation and massive JSON graph traversals.
|
|
||||||
2. **Query Planning Cache Busting**: Generating massive, dynamic SQL strings prevents Postgres from caching query plans. `EXECUTE dynamic_sql` forces the planner to re-evaluate statistics and execution paths on every function call, leading to extreme latency spikes at scale.
|
|
||||||
3. **The Hydration Beat Problem**: The Punc framework requires fetching specific UI "fragments" (e.g. just the `target` of a specific `contact` array element) to feed WebSockets. Hand-rolling CTEs for every possible sub-tree permutation to serve beats will quickly become unmaintainable.
|
|
||||||
|
|
||||||
## The Solution: Semantic Engine Database
|
|
||||||
|
|
||||||
By migrating `merge_entity` and `query_entity` to `jspg`, we turn the database into a pre-compiled Semantic Engine.
|
|
||||||
|
|
||||||
1. **Schema-to-SQL Compilation**: During the connection lifecycle (`cache_json_schemas()`), `jspg` statically analyzes the JSON Schema AST. It acts as a compiler, translating the schema layout into perfectly optimized, multi-JOIN SQL query strings for *every* node/fragment in the schema.
|
|
||||||
2. **Prepared Statements (SPI)**: `jspg` feeds these computed SQL strings into the Postgres SPI (Server Programming Interface) using `Spi::prepare()`. Postgres calculates the query execution plan *once* and caches it in memory.
|
|
||||||
3. **Instant Execution**: When a Punc needs data, `jspg` retrieves the cached PreparedStatement, securely binds binary parameters, and executes the pre-planned query instantly.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### 1. The `cache_json_schemas()` Expansion
|
|
||||||
The initialization function must now ingest `types` and `agreego.relation` data so the internal `Registry` holds the full Relational Graph.
|
|
||||||
|
|
||||||
During schema compilation, if a schema is associated with a database Type, it triggers the **SQL Compiler Phase**:
|
|
||||||
- It builds a table-resolution AST mapping to `JOIN` clauses based on foreign keys.
|
|
||||||
- It translates JSON schema properties to `SELECT jsonb_build_object(...)`.
|
|
||||||
- It generates static SQL for `INSERT`, `UPDATE`, and `SELECT` (including path-based fragment SELECTs).
|
|
||||||
- It calls `Spi::prepare()` to cache these plans inside the Session Context.
|
|
||||||
|
|
||||||
### 2. `agreego.query_entity` (Reads)
|
|
||||||
* **API**: `agreego.query_entity(schema_id TEXT, fragment_path TEXT, cue JSONB)`
|
|
||||||
* **Execution**:
|
|
||||||
* Rust locates the target Schema in memory.
|
|
||||||
* It uses the `fragment_path` (e.g., `/` for a full read, or `/contacts/0/target` for a hydration beat) to fetch the exact PreparedStatement.
|
|
||||||
* It binds variables (Row Level Security IDs, filtering, pagination limit/offset) parsed from the `cue`.
|
|
||||||
* SPI returns the heavily nested, pre-aggregated `JSONB` instantly.
|
|
||||||
|
|
||||||
### 3. Unified Aggregations & Computeds (Schema `query` objects)
|
|
||||||
We replace the concept of a complex string parser (PEL) with native structured JSON JSON objects using the `query` keyword.
|
|
||||||
|
|
||||||
A structured `query` block in the schema:
|
|
||||||
```json
|
|
||||||
"total": {
|
|
||||||
"type": "number",
|
|
||||||
"readOnly": true,
|
|
||||||
"query": {
|
|
||||||
"aggregate": "sum",
|
|
||||||
"source": "lines",
|
|
||||||
"field": "amount"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
* **Frontend (Dart)**: The Go generator parses the JSON object directly and emits the native UI aggregation code (e.g. `lines.fold(...)`) for instant UI updates before the server responds.
|
|
||||||
* **Backend (jspg)**: The Rust SQL compiler natively deserializes the `query` object into an internal struct. It recognizes the `aggregate` instruction and outputs a Postgres native aggregation: `(SELECT SUM(amount) FROM agreego.invoice_line WHERE invoice_id = t1.id)` as a column in the prepared `SELECT` statement.
|
|
||||||
* **Unification**: The database-calculated value acts as the authoritative truth, synchronizing and correcting the client automatically on the resulting `beat`.
|
|
||||||
|
|
||||||
### 4. `agreego.merge_entity` (Writes)
|
|
||||||
* **API**: `agreego.merge_entity(cue JSONB)`
|
|
||||||
* **Execution**:
|
|
||||||
* Parses the incoming `cue` JSON via `serde_json` at C-like speeds.
|
|
||||||
* Recursively validates and *constructively masks* the tree against the strict schema.
|
|
||||||
* Traverses the relational graph (which is fully loaded in the `jspg` registry).
|
|
||||||
* Binds the new values directly into the cached `INSERT` or `UPDATE` SPI prepared statements for each table in the hierarchy.
|
|
||||||
* Evaluates field differences and natively uses `pg_notify` to fire atomic row-level changes for the Go Beat framework.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
1. **Relational Ingestion**: Update `cache_json_schemas` to pass relational metadata (`agreego.relation` rows) into the `jspg` registry cache.
|
|
||||||
2. **The SQL Compiler**: Build the AST-to-String compiler in Rust that reads properties, `$ref`s, and `$family` trees to piece together generic SQL.
|
|
||||||
3. **SPI Caching**: Integrate `Spi::prepare` into the `Validator` creation phase.
|
|
||||||
4. **Rust `merge_entity`**: Port the constructive structural extraction loop from PL/pgSQL to Rust.
|
|
||||||
5. **Rust `query_entity`**: Abstract the query runtime, mapping Punc JSON `filters` arrays to SPI-bound parameters safely.
|
|
||||||
22
src/lib.rs
22
src/lib.rs
@ -31,6 +31,9 @@ fn jspg_failure() -> JsonB {
|
|||||||
message: "JSPG extension has not been initialized via jspg_setup".to_string(),
|
message: "JSPG extension has not been initialized via jspg_setup".to_string(),
|
||||||
details: crate::drop::ErrorDetails {
|
details: crate::drop::ErrorDetails {
|
||||||
path: "".to_string(),
|
path: "".to_string(),
|
||||||
|
cause: None,
|
||||||
|
context: None,
|
||||||
|
schema: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let drop = crate::drop::Drop::with_errors(vec![error]);
|
let drop = crate::drop::Drop::with_errors(vec![error]);
|
||||||
@ -57,7 +60,7 @@ pub fn jspg_setup(database: JsonB) -> JsonB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(test), pg_extern)]
|
#[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
|
// Try to acquire a read lock to get a clone of the Engine Arc
|
||||||
let engine_opt = {
|
let engine_opt = {
|
||||||
let lock = GLOBAL_JSPG.read().unwrap();
|
let lock = GLOBAL_JSPG.read().unwrap();
|
||||||
@ -66,7 +69,7 @@ pub fn jspg_merge(data: JsonB) -> JsonB {
|
|||||||
|
|
||||||
match engine_opt {
|
match engine_opt {
|
||||||
Some(engine) => {
|
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())
|
JsonB(serde_json::to_value(drop).unwrap())
|
||||||
}
|
}
|
||||||
None => jspg_failure(),
|
None => jspg_failure(),
|
||||||
@ -74,7 +77,7 @@ pub fn jspg_merge(data: JsonB) -> JsonB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(test), pg_extern)]
|
#[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 engine_opt = {
|
||||||
let lock = GLOBAL_JSPG.read().unwrap();
|
let lock = GLOBAL_JSPG.read().unwrap();
|
||||||
lock.clone()
|
lock.clone()
|
||||||
@ -84,7 +87,7 @@ pub fn jspg_query(schema_id: &str, stem: Option<&str>, filters: Option<JsonB>) -
|
|||||||
Some(engine) => {
|
Some(engine) => {
|
||||||
let drop = engine
|
let drop = engine
|
||||||
.queryer
|
.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())
|
JsonB(serde_json::to_value(drop).unwrap())
|
||||||
}
|
}
|
||||||
None => jspg_failure(),
|
None => jspg_failure(),
|
||||||
@ -111,9 +114,7 @@ pub fn jspg_validate(schema_id: &str, instance: JsonB) -> JsonB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(test), pg_extern)]
|
#[cfg_attr(not(test), pg_extern)]
|
||||||
pub fn jspg_stems() -> JsonB {
|
pub fn jspg_schemas() -> JsonB {
|
||||||
use serde_json::{Map, Value};
|
|
||||||
|
|
||||||
let engine_opt = {
|
let engine_opt = {
|
||||||
let lock = GLOBAL_JSPG.read().unwrap();
|
let lock = GLOBAL_JSPG.read().unwrap();
|
||||||
lock.clone()
|
lock.clone()
|
||||||
@ -121,9 +122,12 @@ pub fn jspg_stems() -> JsonB {
|
|||||||
|
|
||||||
match engine_opt {
|
match engine_opt {
|
||||||
Some(engine) => {
|
Some(engine) => {
|
||||||
JsonB(serde_json::to_value(&engine.database.stems).unwrap_or(Value::Object(Map::new())))
|
let schemas_json = serde_json::to_value(&engine.database.schemas)
|
||||||
|
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||||
|
let drop = crate::drop::Drop::success_with_val(schemas_json);
|
||||||
|
JsonB(serde_json::to_value(drop).unwrap())
|
||||||
}
|
}
|
||||||
None => JsonB(Value::Object(Map::new())),
|
None => jspg_failure(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
|
|
||||||
|
use crate::database::r#type::Type;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -20,10 +21,60 @@ impl Merger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn merge(&self, data: Value) -> crate::drop::Drop {
|
pub fn merge(&self, schema_id: &str, data: Value) -> crate::drop::Drop {
|
||||||
match self.merge_internal(data) {
|
let mut notifications_queue = Vec::new();
|
||||||
Ok(val) => {
|
|
||||||
let stripped_val = match val {
|
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) => {
|
||||||
|
return crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||||
|
code: "MERGE_FAILED".to_string(),
|
||||||
|
message: msg,
|
||||||
|
details: crate::drop::ErrorDetails {
|
||||||
|
path: "".to_string(),
|
||||||
|
cause: None,
|
||||||
|
context: Some(data),
|
||||||
|
schema: None,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute the globally collected, pre-ordered notifications last!
|
||||||
|
for notify_sql in notifications_queue {
|
||||||
|
if let Err(e) = self.db.execute(¬ify_sql, None) {
|
||||||
|
return crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||||
|
code: "MERGE_FAILED".to_string(),
|
||||||
|
message: format!("Executor Error in pre-ordered notify: {:?}", e),
|
||||||
|
details: crate::drop::ErrorDetails {
|
||||||
|
path: "".to_string(),
|
||||||
|
cause: None,
|
||||||
|
context: None,
|
||||||
|
schema: None,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stripped_val = match val_resolved {
|
||||||
Value::Object(mut map) => {
|
Value::Object(mut map) => {
|
||||||
let mut out = serde_json::Map::new();
|
let mut out = serde_json::Map::new();
|
||||||
if let Some(id) = map.remove("id") {
|
if let Some(id) = map.remove("id") {
|
||||||
@ -50,34 +101,51 @@ impl Merger {
|
|||||||
};
|
};
|
||||||
crate::drop::Drop::success_with_val(stripped_val)
|
crate::drop::Drop::success_with_val(stripped_val)
|
||||||
}
|
}
|
||||||
Err(msg) => crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
|
||||||
code: "MERGE_FAILED".to_string(),
|
|
||||||
message: msg,
|
|
||||||
details: crate::drop::ErrorDetails {
|
|
||||||
path: "".to_string(),
|
|
||||||
},
|
|
||||||
}]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn merge_internal(&self, data: Value) -> Result<Value, String> {
|
pub(crate) fn merge_internal(
|
||||||
|
&self,
|
||||||
|
schema: Arc<crate::database::schema::Schema>,
|
||||||
|
data: Value,
|
||||||
|
notifications: &mut Vec<String>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
match data {
|
match data {
|
||||||
Value::Array(items) => self.merge_array(items),
|
Value::Array(items) => self.merge_array(schema, items, notifications),
|
||||||
Value::Object(map) => self.merge_object(map),
|
Value::Object(map) => self.merge_object(schema, map, notifications),
|
||||||
_ => Err("Invalid merge payload: root must be an Object or Array".to_string()),
|
_ => Err("Invalid merge payload: root must be an Object or Array".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_array(&self, items: Vec<Value>) -> Result<Value, 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();
|
let mut resolved_items = Vec::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
let resolved = self.merge_internal(item)?;
|
let resolved = self.merge_internal(item_schema.clone(), item, notifications)?;
|
||||||
resolved_items.push(resolved);
|
resolved_items.push(resolved);
|
||||||
}
|
}
|
||||||
Ok(Value::Array(resolved_items))
|
Ok(Value::Array(resolved_items))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_object(&self, obj: serde_json::Map<String, Value>) -> Result<Value, String> {
|
fn merge_object(
|
||||||
|
&self,
|
||||||
|
schema: Arc<crate::database::schema::Schema>,
|
||||||
|
obj: serde_json::Map<String, Value>,
|
||||||
|
notifications: &mut Vec<String>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let queue_start = notifications.len();
|
||||||
|
|
||||||
let type_name = match obj.get("type").and_then(|v| v.as_str()) {
|
let type_name = match obj.get("type").and_then(|v| v.as_str()) {
|
||||||
Some(t) => t.to_string(),
|
Some(t) => t.to_string(),
|
||||||
None => return Err("Missing required 'type' field on object".to_string()),
|
None => return Err("Missing required 'type' field on object".to_string()),
|
||||||
@ -88,25 +156,49 @@ impl Merger {
|
|||||||
None => return Err(format!("Unknown entity type: {}", type_name)),
|
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_fields = serde_json::Map::new();
|
||||||
let mut entity_objects = serde_json::Map::new();
|
let mut entity_objects = std::collections::BTreeMap::new();
|
||||||
let mut entity_arrays = serde_json::Map::new();
|
let mut entity_arrays = std::collections::BTreeMap::new();
|
||||||
|
|
||||||
for (k, v) in obj {
|
for (k, v) in obj {
|
||||||
let is_field = type_def.fields.contains(&k) || k == "created";
|
// 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 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 {
|
let typeof_v = match &v {
|
||||||
Value::Object(_) => "object",
|
Value::Object(_) => "object",
|
||||||
Value::Array(_) => "array",
|
Value::Array(_) => "array",
|
||||||
_ => "other",
|
_ => "field", // Malformed edge data?
|
||||||
};
|
};
|
||||||
|
if typeof_v == "object" {
|
||||||
if is_field {
|
entity_objects.insert(k.clone(), (v.clone(), prop_schema.clone()));
|
||||||
entity_fields.insert(k, v);
|
|
||||||
} else if typeof_v == "object" {
|
|
||||||
entity_objects.insert(k, v);
|
|
||||||
} else if typeof_v == "array" {
|
} else if typeof_v == "array" {
|
||||||
entity_arrays.insert(k, v);
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,7 +208,6 @@ impl Merger {
|
|||||||
let mut entity_change_kind = None;
|
let mut entity_change_kind = None;
|
||||||
let mut entity_fetched = None;
|
let mut entity_fetched = None;
|
||||||
|
|
||||||
// 2. Pre-stage the entity (for non-relationships)
|
|
||||||
if !type_def.relationship {
|
if !type_def.relationship {
|
||||||
let (fields, kind, fetched) =
|
let (fields, kind, fetched) =
|
||||||
self.stage_entity(entity_fields.clone(), type_def, &user_id, ×tamp)?;
|
self.stage_entity(entity_fields.clone(), type_def, &user_id, ×tamp)?;
|
||||||
@ -127,31 +218,41 @@ impl Merger {
|
|||||||
|
|
||||||
let mut entity_response = serde_json::Map::new();
|
let mut entity_response = serde_json::Map::new();
|
||||||
|
|
||||||
// 3. Handle related objects
|
for (relation_name, (relative_val, rel_schema)) in entity_objects {
|
||||||
for (relation_name, relative_val) in entity_objects {
|
|
||||||
let mut relative = match relative_val {
|
let mut relative = match relative_val {
|
||||||
Value::Object(m) => m,
|
Value::Object(m) => m,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let relative_relation = self.get_entity_relation(type_def, &relative, &relation_name)?;
|
let relative_type_name = match relative.get("type").and_then(|v| v.as_str()) {
|
||||||
|
Some(t) => t.to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(relation) = relative_relation {
|
if let Some(compiled_edges) = schema.obj.compiled_edges.get() {
|
||||||
let parent_is_source = type_def.hierarchy.contains(&relation.source_type);
|
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 parent_is_source {
|
||||||
// Parent holds FK to Child. Child MUST be generated FIRST.
|
|
||||||
if !relative.contains_key("organization_id") {
|
if !relative.contains_key("organization_id") {
|
||||||
if let Some(org_id) = entity_fields.get("organization_id") {
|
if let Some(org_id) = entity_fields.get("organization_id") {
|
||||||
relative.insert("organization_id".to_string(), org_id.clone());
|
relative.insert("organization_id".to_string(), org_id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let merged_relative = match self.merge_internal(Value::Object(relative))? {
|
let mut merged_relative = match self.merge_internal(rel_schema.clone(), Value::Object(relative), notifications)? {
|
||||||
Value::Object(m) => m,
|
Value::Object(m) => m,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
merged_relative.insert(
|
||||||
|
"type".to_string(),
|
||||||
|
Value::String(relative_type_name),
|
||||||
|
);
|
||||||
|
|
||||||
Self::apply_entity_relation(
|
Self::apply_entity_relation(
|
||||||
&mut entity_fields,
|
&mut entity_fields,
|
||||||
&relation.source_columns,
|
&relation.source_columns,
|
||||||
@ -160,7 +261,6 @@ impl Merger {
|
|||||||
);
|
);
|
||||||
entity_response.insert(relation_name, Value::Object(merged_relative));
|
entity_response.insert(relation_name, Value::Object(merged_relative));
|
||||||
} else {
|
} else {
|
||||||
// Child holds FK back to Parent.
|
|
||||||
if !relative.contains_key("organization_id") {
|
if !relative.contains_key("organization_id") {
|
||||||
if let Some(org_id) = entity_fields.get("organization_id") {
|
if let Some(org_id) = entity_fields.get("organization_id") {
|
||||||
relative.insert("organization_id".to_string(), org_id.clone());
|
relative.insert("organization_id".to_string(), org_id.clone());
|
||||||
@ -174,7 +274,7 @@ impl Merger {
|
|||||||
&entity_fields,
|
&entity_fields,
|
||||||
);
|
);
|
||||||
|
|
||||||
let merged_relative = match self.merge_internal(Value::Object(relative))? {
|
let merged_relative = match self.merge_internal(rel_schema.clone(), Value::Object(relative), notifications)? {
|
||||||
Value::Object(m) => m,
|
Value::Object(m) => m,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
@ -183,8 +283,9 @@ impl Merger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Post-stage the entity (for relationships)
|
|
||||||
if type_def.relationship {
|
if type_def.relationship {
|
||||||
let (fields, kind, fetched) =
|
let (fields, kind, fetched) =
|
||||||
self.stage_entity(entity_fields.clone(), type_def, &user_id, ×tamp)?;
|
self.stage_entity(entity_fields.clone(), type_def, &user_id, ×tamp)?;
|
||||||
@ -193,7 +294,6 @@ impl Merger {
|
|||||||
entity_fetched = fetched;
|
entity_fetched = fetched;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Process the main entity fields
|
|
||||||
self.merge_entity_fields(
|
self.merge_entity_fields(
|
||||||
entity_change_kind.as_deref().unwrap_or(""),
|
entity_change_kind.as_deref().unwrap_or(""),
|
||||||
&type_name,
|
&type_name,
|
||||||
@ -202,13 +302,11 @@ impl Merger {
|
|||||||
entity_fetched.as_ref(),
|
entity_fetched.as_ref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Add main entity fields to response
|
|
||||||
for (k, v) in &entity_fields {
|
for (k, v) in &entity_fields {
|
||||||
entity_response.insert(k.clone(), v.clone());
|
entity_response.insert(k.clone(), v.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Handle related arrays
|
for (relation_name, (relative_val, rel_schema)) in entity_arrays {
|
||||||
for (relation_name, relative_val) in entity_arrays {
|
|
||||||
let relative_arr = match relative_val {
|
let relative_arr = match relative_val {
|
||||||
Value::Array(a) => a,
|
Value::Array(a) => a,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
@ -218,14 +316,9 @@ impl Merger {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let first_relative = match &relative_arr[0] {
|
if let Some(compiled_edges) = schema.obj.compiled_edges.get() {
|
||||||
Value::Object(m) => m,
|
if let Some(edge) = compiled_edges.get(&relation_name) {
|
||||||
_ => continue,
|
if let Some(relation) = self.db.relations.get(&edge.constraint) {
|
||||||
};
|
|
||||||
|
|
||||||
let relative_relation = self.get_entity_relation(type_def, first_relative, &relation_name)?;
|
|
||||||
|
|
||||||
if let Some(relation) = relative_relation {
|
|
||||||
let mut relative_responses = Vec::new();
|
let mut relative_responses = Vec::new();
|
||||||
for relative_item_val in relative_arr {
|
for relative_item_val in relative_arr {
|
||||||
if let Value::Object(mut relative_item) = relative_item_val {
|
if let Value::Object(mut relative_item) = relative_item_val {
|
||||||
@ -242,7 +335,17 @@ impl Merger {
|
|||||||
&entity_fields,
|
&entity_fields,
|
||||||
);
|
);
|
||||||
|
|
||||||
let merged_relative = match self.merge_internal(Value::Object(relative_item))? {
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged_relative =
|
||||||
|
match self.merge_internal(item_schema, Value::Object(relative_item), notifications)? {
|
||||||
Value::Object(m) => m,
|
Value::Object(m) => m,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
@ -253,9 +356,12 @@ impl Merger {
|
|||||||
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!
|
||||||
self.merge_entity_change(
|
let notify_sql = self.merge_entity_change(
|
||||||
|
type_def,
|
||||||
&entity_fields,
|
&entity_fields,
|
||||||
entity_fetched.as_ref(),
|
entity_fetched.as_ref(),
|
||||||
entity_change_kind.as_deref(),
|
entity_change_kind.as_deref(),
|
||||||
@ -263,6 +369,10 @@ impl Merger {
|
|||||||
×tamp,
|
×tamp,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
if let Some(sql) = notify_sql {
|
||||||
|
notifications.insert(queue_start, sql);
|
||||||
|
}
|
||||||
|
|
||||||
// Produce the full tree response
|
// Produce the full tree response
|
||||||
let mut final_response = serde_json::Map::new();
|
let mut final_response = serde_json::Map::new();
|
||||||
if let Some(fetched) = entity_fetched {
|
if let Some(fetched) = entity_fetched {
|
||||||
@ -293,6 +403,23 @@ impl Merger {
|
|||||||
> {
|
> {
|
||||||
let type_name = type_def.name.as_str();
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
let entity_fetched = self.fetch_entity(&entity_fields, type_def)?;
|
let entity_fetched = self.fetch_entity(&entity_fields, type_def)?;
|
||||||
|
|
||||||
let system_keys = vec![
|
let system_keys = vec![
|
||||||
@ -549,11 +676,7 @@ impl Merger {
|
|||||||
for key in &sorted_keys {
|
for key in &sorted_keys {
|
||||||
columns.push(format!("\"{}\"", key));
|
columns.push(format!("\"{}\"", key));
|
||||||
let val = entity_pairs.get(key).unwrap();
|
let val = entity_pairs.get(key).unwrap();
|
||||||
if val.as_str() == Some("") {
|
values.push(Self::format_sql_value(val, key, entity_type));
|
||||||
values.push("NULL".to_string());
|
|
||||||
} else {
|
|
||||||
values.push(Self::quote_literal(val));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if columns.is_empty() {
|
if columns.is_empty() {
|
||||||
@ -587,7 +710,11 @@ impl Merger {
|
|||||||
if val.as_str() == Some("") {
|
if val.as_str() == Some("") {
|
||||||
set_clauses.push(format!("\"{}\" = NULL", key));
|
set_clauses.push(format!("\"{}\" = NULL", key));
|
||||||
} else {
|
} else {
|
||||||
set_clauses.push(format!("\"{}\" = {}", key, Self::quote_literal(val)));
|
set_clauses.push(format!(
|
||||||
|
"\"{}\" = {}",
|
||||||
|
key,
|
||||||
|
Self::format_sql_value(val, key, entity_type)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -609,21 +736,23 @@ impl Merger {
|
|||||||
|
|
||||||
fn merge_entity_change(
|
fn merge_entity_change(
|
||||||
&self,
|
&self,
|
||||||
|
type_obj: &Type,
|
||||||
entity_fields: &serde_json::Map<String, Value>,
|
entity_fields: &serde_json::Map<String, Value>,
|
||||||
entity_fetched: Option<&serde_json::Map<String, Value>>,
|
entity_fetched: Option<&serde_json::Map<String, Value>>,
|
||||||
entity_change_kind: Option<&str>,
|
entity_change_kind: Option<&str>,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
timestamp: &str,
|
timestamp: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<Option<String>, String> {
|
||||||
let change_kind = match entity_change_kind {
|
let change_kind = match entity_change_kind {
|
||||||
Some(k) => k,
|
Some(k) => k,
|
||||||
None => return Ok(()),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let id_str = entity_fields.get("id").unwrap();
|
let id_str = entity_fields.get("id").unwrap();
|
||||||
let type_name = entity_fields.get("type").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";
|
let is_update = change_kind == "update" || change_kind == "delete";
|
||||||
|
|
||||||
if !is_update {
|
if !is_update {
|
||||||
@ -636,7 +765,7 @@ impl Merger {
|
|||||||
];
|
];
|
||||||
for (k, v) in entity_fields {
|
for (k, v) in entity_fields {
|
||||||
if !system_keys.contains(k) {
|
if !system_keys.contains(k) {
|
||||||
changes.insert(k.clone(), v.clone());
|
new_vals.insert(k.clone(), v.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -653,12 +782,13 @@ impl Merger {
|
|||||||
if let Some(fetched) = entity_fetched {
|
if let Some(fetched) = entity_fetched {
|
||||||
let old_val = fetched.get(k).unwrap_or(&Value::Null);
|
let old_val = fetched.get(k).unwrap_or(&Value::Null);
|
||||||
if v != old_val {
|
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();
|
let mut complete = entity_fields.clone();
|
||||||
@ -672,15 +802,27 @@ 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();
|
let mut notification = serde_json::Map::new();
|
||||||
notification.insert("complete".to_string(), Value::Object(complete));
|
notification.insert("complete".to_string(), Value::Object(complete));
|
||||||
if is_update {
|
notification.insert("new".to_string(), new_val_obj.clone());
|
||||||
notification.insert("changes".to_string(), Value::Object(changes.clone()));
|
|
||||||
|
if old_val_obj != Value::Null {
|
||||||
|
notification.insert("old".to_string(), old_val_obj.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut notify_sql = None;
|
||||||
|
if type_obj.historical {
|
||||||
let change_sql = format!(
|
let change_sql = format!(
|
||||||
"INSERT INTO agreego.change (changes, entity_id, id, kind, modified_at, modified_by) VALUES ({}, {}, {}, {}, {}, {})",
|
"INSERT INTO agreego.change (\"old\", \"new\", entity_id, id, kind, modified_at, modified_by) VALUES ({}, {}, {}, {}, {}, {}, {})",
|
||||||
Self::quote_literal(&Value::Object(changes)),
|
Self::quote_literal(&old_val_obj),
|
||||||
|
Self::quote_literal(&new_val_obj),
|
||||||
Self::quote_literal(id_str),
|
Self::quote_literal(id_str),
|
||||||
Self::quote_literal(&Value::String(uuid::Uuid::new_v4().to_string())),
|
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(change_kind.to_string())),
|
||||||
@ -688,21 +830,20 @@ impl Merger {
|
|||||||
Self::quote_literal(&Value::String(user_id.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
|
self
|
||||||
.db
|
.db
|
||||||
.execute(&change_sql, None)
|
.execute(&change_sql, None)
|
||||||
.map_err(|e| format!("Executor Error in change: {:?}", e))?;
|
.map_err(|e| format!("Executor Error in change: {:?}", e))?;
|
||||||
self
|
}
|
||||||
.db
|
|
||||||
.execute(¬ify_sql, None)
|
|
||||||
.map_err(|e| format!("Executor Error in notify: {:?}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
if type_obj.notify {
|
||||||
|
notify_sql = Some(format!(
|
||||||
|
"SELECT pg_notify('entity', {})",
|
||||||
|
Self::quote_literal(&Value::String(Value::Object(notification).to_string()))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(notify_sql)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compare_entities(
|
fn compare_entities(
|
||||||
@ -736,101 +877,7 @@ impl Merger {
|
|||||||
changes
|
changes
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reduce_entity_relations(
|
// Helper Functions
|
||||||
&self,
|
|
||||||
mut matching_relations: Vec<crate::database::relation::Relation>,
|
|
||||||
relative: &serde_json::Map<String, Value>,
|
|
||||||
relation_name: &str,
|
|
||||||
) -> Result<Option<crate::database::relation::Relation>, String> {
|
|
||||||
if matching_relations.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if matching_relations.len() == 1 {
|
|
||||||
return Ok(Some(matching_relations.pop().unwrap()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let exact_match: Vec<_> = matching_relations
|
|
||||||
.iter()
|
|
||||||
.filter(|r| r.prefix.as_deref() == Some(relation_name))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
if exact_match.len() == 1 {
|
|
||||||
return Ok(Some(exact_match.into_iter().next().unwrap()));
|
|
||||||
}
|
|
||||||
|
|
||||||
matching_relations.retain(|r| {
|
|
||||||
if let Some(prefix) = &r.prefix {
|
|
||||||
!relative.contains_key(prefix)
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if matching_relations.len() == 1 {
|
|
||||||
Ok(Some(matching_relations.pop().unwrap()))
|
|
||||||
} else {
|
|
||||||
let constraints: Vec<_> = matching_relations
|
|
||||||
.iter()
|
|
||||||
.map(|r| r.constraint.clone())
|
|
||||||
.collect();
|
|
||||||
Err(format!(
|
|
||||||
"AMBIGUOUS_TYPE_RELATIONS: Could not reduce ambiguous type relations: {}",
|
|
||||||
constraints.join(", ")
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_entity_relation(
|
|
||||||
&self,
|
|
||||||
entity_type: &crate::database::r#type::Type,
|
|
||||||
relative: &serde_json::Map<String, Value>,
|
|
||||||
relation_name: &str,
|
|
||||||
) -> Result<Option<crate::database::relation::Relation>, String> {
|
|
||||||
let relative_type_name = match relative.get("type").and_then(|v| v.as_str()) {
|
|
||||||
Some(t) => t,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let relative_type = match self.db.types.get(relative_type_name) {
|
|
||||||
Some(t) => t,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut relative_relations: Vec<crate::database::relation::Relation> = Vec::new();
|
|
||||||
|
|
||||||
for r in self.db.relations.values() {
|
|
||||||
if r.source_type != "entity" && r.destination_type != "entity" {
|
|
||||||
let condition1 = relative_type.hierarchy.contains(&r.source_type)
|
|
||||||
&& entity_type.hierarchy.contains(&r.destination_type);
|
|
||||||
let condition2 = entity_type.hierarchy.contains(&r.source_type)
|
|
||||||
&& relative_type.hierarchy.contains(&r.destination_type);
|
|
||||||
|
|
||||||
if condition1 || condition2 {
|
|
||||||
relative_relations.push(r.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut relative_relation =
|
|
||||||
self.reduce_entity_relations(relative_relations, relative, relation_name)?;
|
|
||||||
|
|
||||||
if relative_relation.is_none() {
|
|
||||||
let mut poly_relations: Vec<crate::database::relation::Relation> = Vec::new();
|
|
||||||
for r in self.db.relations.values() {
|
|
||||||
if r.destination_type == "entity" {
|
|
||||||
let condition1 = relative_type.hierarchy.contains(&r.source_type);
|
|
||||||
let condition2 = entity_type.hierarchy.contains(&r.source_type);
|
|
||||||
|
|
||||||
if condition1 || condition2 {
|
|
||||||
poly_relations.push(r.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
relative_relation = self.reduce_entity_relations(poly_relations, relative, relation_name)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(relative_relation)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_entity_relation(
|
fn apply_entity_relation(
|
||||||
source_entity: &mut serde_json::Map<String, Value>,
|
source_entity: &mut serde_json::Map<String, Value>,
|
||||||
@ -848,6 +895,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 {
|
fn quote_literal(val: &Value) -> String {
|
||||||
match val {
|
match val {
|
||||||
Value::Null => "NULL".to_string(),
|
Value::Null => "NULL".to_string(),
|
||||||
|
|||||||
@ -1,97 +1,85 @@
|
|||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
pub struct Compiler<'a> {
|
||||||
pub struct SqlCompiler {
|
pub db: &'a Database,
|
||||||
pub db: Arc<Database>,
|
pub filter_keys: &'a [String],
|
||||||
|
pub alias_counter: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SqlCompiler {
|
#[derive(Clone, Debug)]
|
||||||
pub fn new(db: Arc<Database>) -> Self {
|
pub struct Node<'a> {
|
||||||
Self { db }
|
pub schema: std::sync::Arc<crate::database::schema::Schema>,
|
||||||
}
|
pub parent_alias: String,
|
||||||
|
pub parent_type_aliases: Option<std::sync::Arc<std::collections::HashMap<String, String>>>,
|
||||||
|
pub parent_type: Option<&'a crate::database::r#type::Type>,
|
||||||
|
pub parent_schema: Option<std::sync::Arc<crate::database::schema::Schema>>,
|
||||||
|
pub property_name: Option<String>,
|
||||||
|
pub depth: usize,
|
||||||
|
pub ast_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Compiler<'a> {
|
||||||
/// Compiles a JSON schema into a nested PostgreSQL query returning JSONB
|
/// Compiles a JSON schema into a nested PostgreSQL query returning JSONB
|
||||||
pub fn compile(
|
pub fn compile(&self, schema_id: &str, filter_keys: &[String]) -> Result<String, String> {
|
||||||
&self,
|
|
||||||
schema_id: &str,
|
|
||||||
stem_path: Option<&str>,
|
|
||||||
filter_keys: &[String],
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let schema = self
|
let schema = self
|
||||||
.db
|
.db
|
||||||
.schemas
|
.schemas
|
||||||
.get(schema_id)
|
.get(schema_id)
|
||||||
.ok_or_else(|| format!("Schema not found: {}", schema_id))?;
|
.ok_or_else(|| format!("Schema not found: {}", schema_id))?;
|
||||||
|
|
||||||
let resolved_arc;
|
let target_schema = std::sync::Arc::new(schema.clone());
|
||||||
let target_schema = if let Some(path) = stem_path.filter(|p| !p.is_empty() && *p != "/") {
|
|
||||||
if let Some(stems_map) = self.db.stems.get(schema_id) {
|
let mut compiler = Compiler {
|
||||||
if let Some(stem) = stems_map.get(path) {
|
db: &self.db,
|
||||||
resolved_arc = stem.schema.clone();
|
filter_keys,
|
||||||
} else {
|
alias_counter: 0,
|
||||||
return Err(format!(
|
|
||||||
"Stem entity type '{}' not found in schema '{}'",
|
|
||||||
path, schema_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Err(format!(
|
|
||||||
"Stem entity type '{}' not found in schema '{}'",
|
|
||||||
path, schema_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
resolved_arc.as_ref()
|
|
||||||
} else {
|
|
||||||
schema
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// We expect the top level to typically be an Object or Array
|
let node = Node {
|
||||||
let is_stem_query = stem_path.is_some();
|
schema: target_schema,
|
||||||
let (sql, _) = self.walk_schema(target_schema, "t1", None, filter_keys, is_stem_query, 0)?;
|
parent_alias: "t1".to_string(),
|
||||||
|
parent_type_aliases: None,
|
||||||
|
parent_type: None,
|
||||||
|
parent_schema: None,
|
||||||
|
property_name: None,
|
||||||
|
depth: 0,
|
||||||
|
ast_path: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (sql, _) = compiler.compile_node(node)?;
|
||||||
Ok(sql)
|
Ok(sql)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively walks the schema AST emitting native PostgreSQL jsonb mapping
|
/// Recursively walks the schema AST emitting native PostgreSQL jsonb mapping
|
||||||
/// Returns a tuple of (SQL_String, Field_Type)
|
/// Returns a tuple of (SQL_String, Field_Type)
|
||||||
fn walk_schema(
|
fn compile_node(&mut self, node: Node<'a>) -> Result<(String, String), String> {
|
||||||
&self,
|
|
||||||
schema: &crate::database::schema::Schema,
|
|
||||||
parent_alias: &str,
|
|
||||||
prop_name_context: Option<&str>,
|
|
||||||
filter_keys: &[String],
|
|
||||||
is_stem_query: bool,
|
|
||||||
depth: usize,
|
|
||||||
) -> Result<(String, String), String> {
|
|
||||||
// Determine the base schema type (could be an array, object, or literal)
|
// Determine the base schema type (could be an array, object, or literal)
|
||||||
match &schema.obj.type_ {
|
match &node.schema.obj.type_ {
|
||||||
Some(crate::database::schema::SchemaTypeOrArray::Single(t)) if t == "array" => {
|
Some(crate::database::schema::SchemaTypeOrArray::Single(t)) if t == "array" => {
|
||||||
// Handle Arrays:
|
self.compile_array(node)
|
||||||
if let Some(items) = &schema.obj.items {
|
}
|
||||||
|
_ => self.compile_reference(node),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_array(&mut self, node: Node<'a>) -> Result<(String, String), String> {
|
||||||
|
if let Some(items) = &node.schema.obj.items {
|
||||||
|
let next_path = node.ast_path.clone();
|
||||||
|
|
||||||
if let Some(ref_id) = &items.obj.r#ref {
|
if let Some(ref_id) = &items.obj.r#ref {
|
||||||
if let Some(type_def) = self.db.types.get(ref_id) {
|
if let Some(type_def) = self.db.types.get(ref_id) {
|
||||||
if is_stem_query && depth > 0 {
|
let mut entity_node = node.clone();
|
||||||
return Ok(("".to_string(), "abort".to_string()));
|
entity_node.ast_path = next_path;
|
||||||
}
|
entity_node.schema = std::sync::Arc::clone(items);
|
||||||
return self.compile_entity_node(
|
return self.compile_entity(type_def, entity_node, true);
|
||||||
items,
|
|
||||||
type_def,
|
|
||||||
parent_alias,
|
|
||||||
prop_name_context,
|
|
||||||
true,
|
|
||||||
filter_keys,
|
|
||||||
is_stem_query,
|
|
||||||
depth,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (item_sql, _) = self.walk_schema(
|
|
||||||
items,
|
let mut next_node = node.clone();
|
||||||
parent_alias,
|
next_node.depth += 1;
|
||||||
prop_name_context,
|
next_node.ast_path = next_path;
|
||||||
filter_keys,
|
next_node.schema = std::sync::Arc::clone(items);
|
||||||
is_stem_query,
|
let (item_sql, _) = self.compile_node(next_node)?;
|
||||||
depth + 1,
|
|
||||||
)?;
|
|
||||||
return Ok((
|
return Ok((
|
||||||
format!("(SELECT jsonb_agg({}) FROM TODO)", item_sql),
|
format!("(SELECT jsonb_agg({}) FROM TODO)", item_sql),
|
||||||
"array".to_string(),
|
"array".to_string(),
|
||||||
@ -103,120 +91,104 @@ impl SqlCompiler {
|
|||||||
"array".to_string(),
|
"array".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
_ => {
|
|
||||||
|
fn compile_reference(&mut self, node: Node<'a>) -> Result<(String, String), String> {
|
||||||
// Determine if this schema represents a Database Entity
|
// Determine if this schema represents a Database Entity
|
||||||
let mut resolved_type = None;
|
let mut resolved_type = None;
|
||||||
|
|
||||||
// Target is generally a specific schema (e.g. 'base.person'), but it tells us what physical
|
if let Some(family_target) = node.schema.obj.family.as_ref() {
|
||||||
// database table hierarchy it maps to via the `schema.id` prefix/suffix convention.
|
resolved_type = self.db.types.get(family_target);
|
||||||
if let Some(lookup_key) = schema.obj.id.as_ref().or(schema.obj.r#ref.as_ref()) {
|
} else if let Some(lookup_key) = node
|
||||||
|
.schema
|
||||||
|
.obj
|
||||||
|
.id
|
||||||
|
.as_ref()
|
||||||
|
.or(node.schema.obj.r#ref.as_ref())
|
||||||
|
{
|
||||||
let base_type_name = lookup_key.split('.').next_back().unwrap_or("").to_string();
|
let base_type_name = lookup_key.split('.').next_back().unwrap_or("").to_string();
|
||||||
resolved_type = self.db.types.get(&base_type_name);
|
resolved_type = self.db.types.get(&base_type_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(type_def) = resolved_type {
|
if let Some(type_def) = resolved_type {
|
||||||
if is_stem_query && depth > 0 {
|
return self.compile_entity(type_def, node.clone(), false);
|
||||||
return Ok(("".to_string(), "abort".to_string()));
|
|
||||||
}
|
|
||||||
return self.compile_entity_node(
|
|
||||||
schema,
|
|
||||||
type_def,
|
|
||||||
parent_alias,
|
|
||||||
prop_name_context,
|
|
||||||
false,
|
|
||||||
filter_keys,
|
|
||||||
is_stem_query,
|
|
||||||
depth,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Direct Refs
|
// Handle Direct Refs
|
||||||
if let Some(ref_id) = &schema.obj.r#ref {
|
if let Some(ref_id) = &node.schema.obj.r#ref {
|
||||||
// If it's just an ad-hoc struct ref, we should resolve it
|
// If it's just an ad-hoc struct ref, we should resolve it
|
||||||
if let Some(target_schema) = self.db.schemas.get(ref_id) {
|
if let Some(target_schema) = self.db.schemas.get(ref_id) {
|
||||||
return self.walk_schema(
|
let mut ref_node = node.clone();
|
||||||
target_schema,
|
ref_node.schema = std::sync::Arc::new(target_schema.clone());
|
||||||
parent_alias,
|
return self.compile_node(ref_node);
|
||||||
prop_name_context,
|
|
||||||
filter_keys,
|
|
||||||
is_stem_query,
|
|
||||||
depth,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Err(format!("Unresolved $ref: {}", ref_id));
|
return Err(format!("Unresolved $ref: {}", ref_id));
|
||||||
}
|
}
|
||||||
|
// Handle $family Polymorphism fallbacks for relations
|
||||||
|
if let Some(family_target) = &node.schema.obj.family {
|
||||||
|
let base_type_name = family_target
|
||||||
|
.split('.')
|
||||||
|
.next_back()
|
||||||
|
.unwrap_or(family_target)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if let Some(type_def) = self.db.types.get(&base_type_name) {
|
||||||
|
if type_def.variations.len() == 1 {
|
||||||
|
let mut bypass_schema = crate::database::schema::Schema::default();
|
||||||
|
bypass_schema.obj.r#ref = Some(family_target.clone());
|
||||||
|
let mut bypass_node = node.clone();
|
||||||
|
bypass_node.schema = std::sync::Arc::new(bypass_schema);
|
||||||
|
return self.compile_node(bypass_node);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted_variations: Vec<String> = type_def.variations.iter().cloned().collect();
|
||||||
|
sorted_variations.sort();
|
||||||
|
|
||||||
|
let mut family_schemas = Vec::new();
|
||||||
|
for variation in &sorted_variations {
|
||||||
|
let mut ref_schema = crate::database::schema::Schema::default();
|
||||||
|
ref_schema.obj.r#ref = Some(variation.clone());
|
||||||
|
family_schemas.push(std::sync::Arc::new(ref_schema));
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.compile_one_of(&family_schemas, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle oneOf Polymorphism fallbacks for relations
|
||||||
|
if let Some(one_of) = &node.schema.obj.one_of {
|
||||||
|
return self.compile_one_of(one_of, node.clone());
|
||||||
|
}
|
||||||
|
|
||||||
// Just an inline object definition?
|
// Just an inline object definition?
|
||||||
if let Some(props) = &schema.obj.properties {
|
if let Some(props) = &node.schema.obj.properties {
|
||||||
return self.compile_inline_object(
|
return self.compile_object(props, node.clone());
|
||||||
props,
|
|
||||||
parent_alias,
|
|
||||||
filter_keys,
|
|
||||||
is_stem_query,
|
|
||||||
depth,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Literal fallback
|
// Literal fallback
|
||||||
Ok((
|
Ok((
|
||||||
format!(
|
format!(
|
||||||
"{}.{}",
|
"{}.{}",
|
||||||
parent_alias,
|
node.parent_alias,
|
||||||
prop_name_context.unwrap_or("unknown_prop")
|
node.property_name.as_deref().unwrap_or("unknown_prop")
|
||||||
),
|
),
|
||||||
"string".to_string(),
|
"string".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_merged_properties(
|
fn compile_entity(
|
||||||
&self,
|
&mut self,
|
||||||
schema: &crate::database::schema::Schema,
|
r#type: &'a crate::database::r#type::Type,
|
||||||
) -> std::collections::BTreeMap<String, Arc<crate::database::schema::Schema>> {
|
node: Node<'a>,
|
||||||
let mut props = std::collections::BTreeMap::new();
|
|
||||||
|
|
||||||
if let Some(ref_id) = &schema.obj.r#ref {
|
|
||||||
if let Some(parent_schema) = self.db.schemas.get(ref_id) {
|
|
||||||
props.extend(self.get_merged_properties(parent_schema));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(local_props) = &schema.obj.properties {
|
|
||||||
for (k, v) in local_props {
|
|
||||||
props.insert(k.clone(), v.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
props
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compile_entity_node(
|
|
||||||
&self,
|
|
||||||
schema: &crate::database::schema::Schema,
|
|
||||||
type_def: &crate::database::r#type::Type,
|
|
||||||
parent_alias: &str,
|
|
||||||
prop_name: Option<&str>,
|
|
||||||
is_array: bool,
|
is_array: bool,
|
||||||
filter_keys: &[String],
|
|
||||||
is_stem_query: bool,
|
|
||||||
depth: usize,
|
|
||||||
) -> Result<(String, String), String> {
|
) -> Result<(String, String), String> {
|
||||||
let local_ctx = format!("{}_{}", parent_alias, prop_name.unwrap_or("obj"));
|
let (table_aliases, from_clauses) = self.compile_from_clause(r#type);
|
||||||
|
|
||||||
// 1. Build FROM clauses and table aliases
|
|
||||||
let (mut table_aliases, from_clauses) = self.build_hierarchy_from_clauses(type_def, &local_ctx);
|
|
||||||
|
|
||||||
// 2. Map properties and build jsonb_build_object args
|
// 2. Map properties and build jsonb_build_object args
|
||||||
let select_args = self.map_properties_to_aliases(
|
let mut select_args = self.compile_select_clause(r#type, &table_aliases, node.clone())?;
|
||||||
schema,
|
|
||||||
type_def,
|
// 2.5 Inject polymorphism directly into the query object
|
||||||
&table_aliases,
|
let mut poly_args = self.compile_polymorphism_select(r#type, &table_aliases, node.clone())?;
|
||||||
parent_alias,
|
select_args.append(&mut poly_args);
|
||||||
filter_keys,
|
|
||||||
is_stem_query,
|
|
||||||
depth,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let jsonb_obj_sql = if select_args.is_empty() {
|
let jsonb_obj_sql = if select_args.is_empty() {
|
||||||
"jsonb_build_object()".to_string()
|
"jsonb_build_object()".to_string()
|
||||||
@ -225,14 +197,7 @@ impl SqlCompiler {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 3. Build WHERE clauses
|
// 3. Build WHERE clauses
|
||||||
let mut where_clauses = self.build_filter_where_clauses(
|
let where_clauses = self.compile_where_clause(r#type, &table_aliases, node)?;
|
||||||
schema,
|
|
||||||
type_def,
|
|
||||||
&table_aliases,
|
|
||||||
parent_alias,
|
|
||||||
prop_name,
|
|
||||||
filter_keys,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let selection = if is_array {
|
let selection = if is_array {
|
||||||
format!("COALESCE(jsonb_agg({}), '[]'::jsonb)", jsonb_obj_sql)
|
format!("COALESCE(jsonb_agg({}), '[]'::jsonb)", jsonb_obj_sql)
|
||||||
@ -257,22 +222,162 @@ impl SqlCompiler {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_hierarchy_from_clauses(
|
fn compile_polymorphism_select(
|
||||||
&self,
|
&mut self,
|
||||||
type_def: &crate::database::r#type::Type,
|
r#type: &'a crate::database::r#type::Type,
|
||||||
local_ctx: &str,
|
table_aliases: &std::collections::HashMap<String, String>,
|
||||||
|
node: Node<'a>,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let mut select_args = Vec::new();
|
||||||
|
|
||||||
|
if let Some(family_target) = node.schema.obj.family.as_ref() {
|
||||||
|
let base_type_name = family_target
|
||||||
|
.split('.')
|
||||||
|
.next_back()
|
||||||
|
.unwrap_or(family_target)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if let Some(fam_type_def) = self.db.types.get(&base_type_name) {
|
||||||
|
if fam_type_def.variations.len() == 1 {
|
||||||
|
let mut bypass_schema = crate::database::schema::Schema::default();
|
||||||
|
bypass_schema.obj.r#ref = Some(family_target.clone());
|
||||||
|
bypass_schema.compile(self.db, &mut std::collections::HashSet::new());
|
||||||
|
|
||||||
|
let mut bypass_node = node.clone();
|
||||||
|
bypass_node.schema = std::sync::Arc::new(bypass_schema);
|
||||||
|
|
||||||
|
let mut bypassed_args = self.compile_select_clause(r#type, table_aliases, bypass_node)?;
|
||||||
|
select_args.append(&mut bypassed_args);
|
||||||
|
} else {
|
||||||
|
let mut family_schemas = Vec::new();
|
||||||
|
let mut sorted_fam_variations: Vec<String> =
|
||||||
|
fam_type_def.variations.iter().cloned().collect();
|
||||||
|
sorted_fam_variations.sort();
|
||||||
|
|
||||||
|
for variation in &sorted_fam_variations {
|
||||||
|
let mut ref_schema = crate::database::schema::Schema::default();
|
||||||
|
ref_schema.obj.r#ref = Some(variation.clone());
|
||||||
|
ref_schema.compile(self.db, &mut std::collections::HashSet::new());
|
||||||
|
family_schemas.push(std::sync::Arc::new(ref_schema));
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_alias = table_aliases
|
||||||
|
.get(&r#type.name)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| node.parent_alias.to_string());
|
||||||
|
select_args.push(format!("'id', {}.id", base_alias));
|
||||||
|
let mut case_node = node.clone();
|
||||||
|
case_node.parent_alias = base_alias.clone();
|
||||||
|
let arc_aliases = std::sync::Arc::new(table_aliases.clone());
|
||||||
|
case_node.parent_type_aliases = Some(arc_aliases);
|
||||||
|
|
||||||
|
let (case_sql, _) = self.compile_one_of(&family_schemas, case_node)?;
|
||||||
|
select_args.push(format!("'type', {}", case_sql));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(one_of) = &node.schema.obj.one_of {
|
||||||
|
let base_alias = table_aliases
|
||||||
|
.get(&r#type.name)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| node.parent_alias.to_string());
|
||||||
|
select_args.push(format!("'id', {}.id", base_alias));
|
||||||
|
let mut case_node = node.clone();
|
||||||
|
case_node.parent_alias = base_alias.clone();
|
||||||
|
let arc_aliases = std::sync::Arc::new(table_aliases.clone());
|
||||||
|
case_node.parent_type_aliases = Some(arc_aliases);
|
||||||
|
|
||||||
|
let (case_sql, _) = self.compile_one_of(one_of, case_node)?;
|
||||||
|
select_args.push(format!("'type', {}", case_sql));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(select_args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_object(
|
||||||
|
&mut self,
|
||||||
|
props: &std::collections::BTreeMap<String, std::sync::Arc<crate::database::schema::Schema>>,
|
||||||
|
node: Node<'a>,
|
||||||
|
) -> Result<(String, String), String> {
|
||||||
|
let mut build_args = Vec::new();
|
||||||
|
for (k, v) in props {
|
||||||
|
let next_path = if node.ast_path.is_empty() {
|
||||||
|
k.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}.{}", node.ast_path, k)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut child_node = node.clone();
|
||||||
|
child_node.property_name = Some(k.clone());
|
||||||
|
child_node.depth += 1;
|
||||||
|
child_node.ast_path = next_path;
|
||||||
|
child_node.schema = std::sync::Arc::clone(v);
|
||||||
|
|
||||||
|
let (child_sql, val_type) = self.compile_node(child_node)?;
|
||||||
|
if val_type == "abort" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
build_args.push(format!("'{}', {}", k, child_sql));
|
||||||
|
}
|
||||||
|
let combined = format!("jsonb_build_object({})", build_args.join(", "));
|
||||||
|
Ok((combined, "object".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_one_of(
|
||||||
|
&mut self,
|
||||||
|
schemas: &[Arc<crate::database::schema::Schema>],
|
||||||
|
node: Node<'a>,
|
||||||
|
) -> Result<(String, String), String> {
|
||||||
|
let mut case_statements = Vec::new();
|
||||||
|
let type_col = if let Some(prop) = &node.property_name {
|
||||||
|
format!("{}_type", prop)
|
||||||
|
} else {
|
||||||
|
"type".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
for option_schema in schemas {
|
||||||
|
if let Some(ref_id) = &option_schema.obj.r#ref {
|
||||||
|
// Find the physical type this ref maps to
|
||||||
|
let base_type_name = ref_id.split('.').next_back().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
// Generate the nested SQL for this specific target type
|
||||||
|
let mut child_node = node.clone();
|
||||||
|
child_node.schema = std::sync::Arc::clone(option_schema);
|
||||||
|
let (val_sql, _) = self.compile_node(child_node)?;
|
||||||
|
|
||||||
|
case_statements.push(format!(
|
||||||
|
"WHEN {}.{} = '{}' THEN ({})",
|
||||||
|
node.parent_alias, type_col, base_type_name, val_sql
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if case_statements.is_empty() {
|
||||||
|
return Ok(("NULL".to_string(), "string".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
case_statements.sort();
|
||||||
|
|
||||||
|
let sql = format!("CASE {} ELSE NULL END", case_statements.join(" "));
|
||||||
|
|
||||||
|
Ok((sql, "object".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_from_clause(
|
||||||
|
&mut self,
|
||||||
|
r#type: &crate::database::r#type::Type,
|
||||||
) -> (std::collections::HashMap<String, String>, Vec<String>) {
|
) -> (std::collections::HashMap<String, String>, Vec<String>) {
|
||||||
let mut table_aliases = std::collections::HashMap::new();
|
let mut table_aliases = std::collections::HashMap::new();
|
||||||
let mut from_clauses = Vec::new();
|
let mut from_clauses = Vec::new();
|
||||||
|
|
||||||
for (i, table_name) in type_def.hierarchy.iter().enumerate() {
|
for (i, table_name) in r#type.hierarchy.iter().enumerate() {
|
||||||
let alias = format!("{}_t{}", local_ctx, i + 1);
|
self.alias_counter += 1;
|
||||||
|
let alias = format!("{}_{}", table_name, self.alias_counter);
|
||||||
table_aliases.insert(table_name.clone(), alias.clone());
|
table_aliases.insert(table_name.clone(), alias.clone());
|
||||||
|
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
from_clauses.push(format!("agreego.{} {}", table_name, alias));
|
from_clauses.push(format!("agreego.{} {}", table_name, alias));
|
||||||
} else {
|
} else {
|
||||||
let prev_alias = format!("{}_t{}", local_ctx, i);
|
let prev_alias = format!("{}_{}", r#type.hierarchy[i - 1], self.alias_counter - 1);
|
||||||
from_clauses.push(format!(
|
from_clauses.push(format!(
|
||||||
"JOIN agreego.{} {} ON {}.id = {}.id",
|
"JOIN agreego.{} {} ON {}.id = {}.id",
|
||||||
table_name, alias, alias, prev_alias
|
table_name, alias, alias, prev_alias
|
||||||
@ -282,25 +387,48 @@ impl SqlCompiler {
|
|||||||
(table_aliases, from_clauses)
|
(table_aliases, from_clauses)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_properties_to_aliases(
|
fn compile_select_clause(
|
||||||
&self,
|
&mut self,
|
||||||
schema: &crate::database::schema::Schema,
|
r#type: &'a crate::database::r#type::Type,
|
||||||
type_def: &crate::database::r#type::Type,
|
|
||||||
table_aliases: &std::collections::HashMap<String, String>,
|
table_aliases: &std::collections::HashMap<String, String>,
|
||||||
parent_alias: &str,
|
node: Node<'a>,
|
||||||
filter_keys: &[String],
|
|
||||||
is_stem_query: bool,
|
|
||||||
depth: usize,
|
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, String> {
|
||||||
let mut select_args = Vec::new();
|
let mut select_args = Vec::new();
|
||||||
let grouped_fields = type_def.grouped_fields.as_ref().and_then(|v| v.as_object());
|
let grouped_fields = r#type.grouped_fields.as_ref().and_then(|v| v.as_object());
|
||||||
let merged_props = self.get_merged_properties(schema);
|
let merged_props = node.schema.obj.compiled_properties.get().unwrap();
|
||||||
|
let mut sorted_keys: Vec<&String> = merged_props.keys().collect();
|
||||||
|
sorted_keys.sort();
|
||||||
|
|
||||||
|
for prop_key in sorted_keys {
|
||||||
|
let prop_schema = &merged_props[prop_key];
|
||||||
|
|
||||||
|
let is_object_or_array = match &prop_schema.obj.type_ {
|
||||||
|
Some(crate::database::schema::SchemaTypeOrArray::Single(s)) => {
|
||||||
|
s == "object" || s == "array"
|
||||||
|
}
|
||||||
|
Some(crate::database::schema::SchemaTypeOrArray::Multiple(v)) => {
|
||||||
|
v.contains(&"object".to_string()) || v.contains(&"array".to_string())
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_primitive = prop_schema.obj.r#ref.is_none()
|
||||||
|
&& !is_object_or_array
|
||||||
|
&& prop_schema.obj.family.is_none()
|
||||||
|
&& prop_schema.obj.one_of.is_none();
|
||||||
|
|
||||||
|
if is_primitive {
|
||||||
|
if let Some(ft) = r#type.field_types.as_ref().and_then(|v| v.as_object()) {
|
||||||
|
if !ft.contains_key(prop_key) {
|
||||||
|
continue; // Skip frontend virtual properties missing from physical table fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (prop_key, prop_schema) in &merged_props {
|
|
||||||
let mut owner_alias = table_aliases
|
let mut owner_alias = table_aliases
|
||||||
.get("entity")
|
.get("entity")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| format!("{}_t_err", parent_alias));
|
.unwrap_or_else(|| format!("{}_t_err", node.parent_alias));
|
||||||
|
|
||||||
if let Some(gf) = grouped_fields {
|
if let Some(gf) = grouped_fields {
|
||||||
for (t_name, fields_val) in gf {
|
for (t_name, fields_val) in gf {
|
||||||
@ -309,21 +437,30 @@ impl SqlCompiler {
|
|||||||
owner_alias = table_aliases
|
owner_alias = table_aliases
|
||||||
.get(t_name)
|
.get(t_name)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| parent_alias.to_string());
|
.unwrap_or_else(|| node.parent_alias.to_string());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (val_sql, val_type) = self.walk_schema(
|
let child_node = Node {
|
||||||
prop_schema,
|
schema: std::sync::Arc::clone(prop_schema),
|
||||||
&owner_alias,
|
parent_alias: owner_alias.clone(),
|
||||||
Some(prop_key),
|
parent_type_aliases: Some(std::sync::Arc::new(table_aliases.clone())),
|
||||||
filter_keys,
|
parent_type: Some(r#type),
|
||||||
is_stem_query,
|
parent_schema: Some(std::sync::Arc::clone(&node.schema)),
|
||||||
depth + 1,
|
property_name: Some(prop_key.clone()),
|
||||||
)?;
|
depth: node.depth + 1,
|
||||||
|
ast_path: if node.ast_path.is_empty() {
|
||||||
|
prop_key.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", node.ast_path, prop_key)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let (val_sql, val_type) = self.compile_node(child_node)?;
|
||||||
|
|
||||||
if val_type != "abort" {
|
if val_type != "abort" {
|
||||||
select_args.push(format!("'{}', {}", prop_key, val_sql));
|
select_args.push(format!("'{}', {}", prop_key, val_sql));
|
||||||
@ -332,59 +469,130 @@ impl SqlCompiler {
|
|||||||
Ok(select_args)
|
Ok(select_args)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_filter_where_clauses(
|
fn compile_where_clause(
|
||||||
&self,
|
&self,
|
||||||
schema: &crate::database::schema::Schema,
|
r#type: &'a crate::database::r#type::Type,
|
||||||
type_def: &crate::database::r#type::Type,
|
type_aliases: &std::collections::HashMap<String, String>,
|
||||||
table_aliases: &std::collections::HashMap<String, String>,
|
node: Node<'a>,
|
||||||
parent_alias: &str,
|
|
||||||
prop_name: Option<&str>,
|
|
||||||
filter_keys: &[String],
|
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, String> {
|
||||||
let base_alias = table_aliases
|
let base_alias = type_aliases
|
||||||
.get(&type_def.name)
|
.get(&r#type.name)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| "err".to_string());
|
.unwrap_or_else(|| "err".to_string());
|
||||||
|
|
||||||
|
let entity_alias = type_aliases
|
||||||
|
.get("entity")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| base_alias.clone());
|
||||||
|
|
||||||
let mut where_clauses = Vec::new();
|
let mut where_clauses = Vec::new();
|
||||||
where_clauses.push(format!("NOT {}.archived", base_alias));
|
|
||||||
|
|
||||||
if parent_alias == "t1" {
|
// Dynamically apply the 'active-only' default ONLY if the client
|
||||||
for (i, filter_key) in filter_keys.iter().enumerate() {
|
// didn't explicitly request to filter on 'archived' themselves!
|
||||||
let mut parts = filter_key.split(':');
|
let has_archived_override = self.filter_keys.iter().any(|k| k == "archived");
|
||||||
let field_name = parts.next().unwrap_or(filter_key);
|
|
||||||
let op = parts.next().unwrap_or("$eq");
|
|
||||||
|
|
||||||
let mut filter_alias = base_alias.clone();
|
if !has_archived_override {
|
||||||
|
where_clauses.push(format!("NOT {}.archived", entity_alias));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(gf) = type_def.grouped_fields.as_ref().and_then(|v| v.as_object()) {
|
self.compile_filter_conditions(r#type, type_aliases, &node, &base_alias, &mut where_clauses);
|
||||||
|
self.compile_polymorphic_bounds(r#type, type_aliases, &node, &mut where_clauses);
|
||||||
|
self.compile_relation_conditions(
|
||||||
|
r#type,
|
||||||
|
type_aliases,
|
||||||
|
&node,
|
||||||
|
&base_alias,
|
||||||
|
&mut where_clauses,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(where_clauses)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_polymorphic_bounds(
|
||||||
|
&self,
|
||||||
|
_type: &crate::database::r#type::Type,
|
||||||
|
type_aliases: &std::collections::HashMap<String, String>,
|
||||||
|
node: &Node,
|
||||||
|
where_clauses: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
if let Some(edges) = node.schema.obj.compiled_edges.get() {
|
||||||
|
if let Some(props) = node.schema.obj.compiled_properties.get() {
|
||||||
|
for (prop_name, edge) in edges {
|
||||||
|
if let Some(prop_schema) = props.get(prop_name) {
|
||||||
|
// Determine if the property schema resolves to a physical Database Entity
|
||||||
|
let mut bound_type_name = None;
|
||||||
|
if let Some(family_target) = prop_schema.obj.family.as_ref() {
|
||||||
|
bound_type_name = Some(family_target.split('.').next_back().unwrap_or(family_target).to_string());
|
||||||
|
} else if let Some(lookup_key) = prop_schema.obj.id.as_ref().or(prop_schema.obj.r#ref.as_ref()) {
|
||||||
|
bound_type_name = Some(lookup_key.split('.').next_back().unwrap_or(lookup_key).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(type_name) = bound_type_name {
|
||||||
|
// Ensure this type actually exists
|
||||||
|
if self.db.types.contains_key(&type_name) {
|
||||||
|
if let Some(relation) = self.db.relations.get(&edge.constraint) {
|
||||||
|
let mut poly_col = None;
|
||||||
|
let mut table_to_alias = "";
|
||||||
|
|
||||||
|
if edge.forward && relation.source_columns.len() > 1 {
|
||||||
|
poly_col = Some(&relation.source_columns[1]); // e.g., target_type
|
||||||
|
table_to_alias = &relation.source_type; // e.g., relationship
|
||||||
|
} else if !edge.forward && relation.destination_columns.len() > 1 {
|
||||||
|
poly_col = Some(&relation.destination_columns[1]); // e.g., source_type
|
||||||
|
table_to_alias = &relation.destination_type; // e.g., relationship
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(col) = poly_col {
|
||||||
|
if let Some(alias) = type_aliases.get(table_to_alias).or_else(|| type_aliases.get(&node.parent_alias)) {
|
||||||
|
where_clauses.push(format!("{}.{} = '{}'", alias, col, type_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_filter_alias(
|
||||||
|
r#type: &crate::database::r#type::Type,
|
||||||
|
type_aliases: &std::collections::HashMap<String, String>,
|
||||||
|
base_alias: &str,
|
||||||
|
field_name: &str,
|
||||||
|
) -> String {
|
||||||
|
if let Some(gf) = r#type.grouped_fields.as_ref().and_then(|v| v.as_object()) {
|
||||||
for (t_name, fields_val) in gf {
|
for (t_name, fields_val) in gf {
|
||||||
if let Some(fields_arr) = fields_val.as_array() {
|
if let Some(fields_arr) = fields_val.as_array() {
|
||||||
if fields_arr.iter().any(|v| v.as_str() == Some(field_name)) {
|
if fields_arr.iter().any(|v| v.as_str() == Some(field_name)) {
|
||||||
filter_alias = table_aliases
|
return type_aliases
|
||||||
.get(t_name)
|
.get(t_name)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| base_alias.clone());
|
.unwrap_or_else(|| base_alias.to_string());
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
base_alias.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn determine_sql_cast_and_op(
|
||||||
|
r#type: &crate::database::r#type::Type,
|
||||||
|
node: &Node,
|
||||||
|
field_name: &str,
|
||||||
|
) -> (&'static str, bool) {
|
||||||
let mut is_ilike = false;
|
let mut is_ilike = false;
|
||||||
let mut cast = "";
|
let mut cast = "";
|
||||||
|
|
||||||
if let Some(field_types) = type_def.field_types.as_ref().and_then(|v| v.as_object()) {
|
if let Some(field_types) = r#type.field_types.as_ref().and_then(|v| v.as_object()) {
|
||||||
if let Some(pg_type_val) = field_types.get(field_name) {
|
if let Some(pg_type_val) = field_types.get(field_name) {
|
||||||
if let Some(pg_type) = pg_type_val.as_str() {
|
if let Some(pg_type) = pg_type_val.as_str() {
|
||||||
if pg_type == "uuid" {
|
if pg_type == "uuid" {
|
||||||
cast = "::uuid";
|
cast = "::uuid";
|
||||||
} else if pg_type == "boolean" || pg_type == "bool" {
|
} else if pg_type == "boolean" || pg_type == "bool" {
|
||||||
cast = "::boolean";
|
cast = "::boolean";
|
||||||
} else if pg_type.contains("timestamp")
|
} else if pg_type.contains("timestamp") || pg_type == "timestamptz" || pg_type == "date" {
|
||||||
|| pg_type == "timestamptz"
|
|
||||||
|| pg_type == "date"
|
|
||||||
{
|
|
||||||
cast = "::timestamptz";
|
cast = "::timestamptz";
|
||||||
} else if pg_type == "numeric"
|
} else if pg_type == "numeric"
|
||||||
|| pg_type.contains("int")
|
|| pg_type.contains("int")
|
||||||
@ -394,7 +602,7 @@ impl SqlCompiler {
|
|||||||
cast = "::numeric";
|
cast = "::numeric";
|
||||||
} else if pg_type == "text" || pg_type.contains("char") {
|
} else if pg_type == "text" || pg_type.contains("char") {
|
||||||
let mut is_enum = false;
|
let mut is_enum = false;
|
||||||
if let Some(props) = &schema.obj.properties {
|
if let Some(props) = &node.schema.obj.properties {
|
||||||
if let Some(ps) = props.get(field_name) {
|
if let Some(ps) = props.get(field_name) {
|
||||||
is_enum = ps.obj.enum_.is_some();
|
is_enum = ps.obj.enum_.is_some();
|
||||||
}
|
}
|
||||||
@ -406,6 +614,42 @@ impl SqlCompiler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(cast, is_ilike)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_filter_conditions(
|
||||||
|
&self,
|
||||||
|
r#type: &crate::database::r#type::Type,
|
||||||
|
type_aliases: &std::collections::HashMap<String, String>,
|
||||||
|
node: &Node,
|
||||||
|
base_alias: &str,
|
||||||
|
where_clauses: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
for (i, filter_key) in self.filter_keys.iter().enumerate() {
|
||||||
|
let mut parts = filter_key.split(':');
|
||||||
|
let full_field_path = parts.next().unwrap_or(filter_key);
|
||||||
|
let op = parts.next().unwrap_or("$eq");
|
||||||
|
|
||||||
|
let field_name = if node.ast_path.is_empty() {
|
||||||
|
if full_field_path.contains('/') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
full_field_path
|
||||||
|
} else {
|
||||||
|
let prefix = format!("{}/", node.ast_path);
|
||||||
|
if full_field_path.starts_with(&prefix) {
|
||||||
|
let remainder = &full_field_path[prefix.len()..];
|
||||||
|
if remainder.contains('/') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
remainder
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let filter_alias = Self::resolve_filter_alias(r#type, type_aliases, base_alias, field_name);
|
||||||
|
let (cast, is_ilike) = Self::determine_sql_cast_and_op(r#type, node, field_name);
|
||||||
|
|
||||||
let param_index = i + 1;
|
let param_index = i + 1;
|
||||||
let p_val = format!("${}#>>'{{}}'", param_index);
|
let p_val = format!("${}#>>'{{}}'", param_index);
|
||||||
@ -463,37 +707,73 @@ impl SqlCompiler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(_prop) = prop_name {
|
fn compile_relation_conditions(
|
||||||
where_clauses.push(format!("{}.parent_id = {}.id", base_alias, parent_alias));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(where_clauses)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compile_inline_object(
|
|
||||||
&self,
|
&self,
|
||||||
props: &std::collections::BTreeMap<String, std::sync::Arc<crate::database::schema::Schema>>,
|
r#type: &crate::database::r#type::Type,
|
||||||
parent_alias: &str,
|
type_aliases: &std::collections::HashMap<String, String>,
|
||||||
filter_keys: &[String],
|
node: &Node,
|
||||||
is_stem_query: bool,
|
base_alias: &str,
|
||||||
depth: usize,
|
where_clauses: &mut Vec<String>,
|
||||||
) -> Result<(String, String), String> {
|
) -> Result<(), String> {
|
||||||
let mut build_args = Vec::new();
|
if let Some(prop_ref) = &node.property_name {
|
||||||
for (k, v) in props {
|
let prop = prop_ref.as_str();
|
||||||
let (child_sql, val_type) = self.walk_schema(
|
println!("DEBUG: Eval prop: {}", prop);
|
||||||
v,
|
|
||||||
parent_alias,
|
let mut parent_relation_alias = node.parent_alias.clone();
|
||||||
Some(k),
|
let mut child_relation_alias = base_alias.to_string();
|
||||||
filter_keys,
|
|
||||||
is_stem_query,
|
if let Some(parent_type) = node.parent_type {
|
||||||
depth + 1,
|
if let Some(parent_schema) = &node.parent_schema {
|
||||||
)?;
|
if let Some(compiled_edges) = parent_schema.obj.compiled_edges.get() {
|
||||||
if val_type == "abort" {
|
if let Some(edge) = compiled_edges.get(prop) {
|
||||||
continue;
|
let is_parent_source = edge.forward;
|
||||||
|
let relation = self.db.relations.get(&edge.constraint).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Could not find exact relation constraint {} statically mapped from {} -> {} property {}",
|
||||||
|
edge.constraint, parent_type.name, r#type.name, prop
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let source_col = &relation.source_columns[0];
|
||||||
|
let dest_col = &relation.destination_columns[0];
|
||||||
|
|
||||||
|
if let Some(pta) = &node.parent_type_aliases {
|
||||||
|
let p_search_type = if is_parent_source {
|
||||||
|
&relation.source_type
|
||||||
|
} else {
|
||||||
|
&relation.destination_type
|
||||||
|
};
|
||||||
|
if let Some(a) = pta.get(p_search_type) {
|
||||||
|
parent_relation_alias = a.clone();
|
||||||
}
|
}
|
||||||
build_args.push(format!("'{}', {}", k, child_sql));
|
|
||||||
}
|
}
|
||||||
let combined = format!("jsonb_build_object({})", build_args.join(", "));
|
|
||||||
Ok((combined, "object".to_string()))
|
let c_search_type = if is_parent_source {
|
||||||
|
&relation.destination_type
|
||||||
|
} else {
|
||||||
|
&relation.source_type
|
||||||
|
};
|
||||||
|
if let Some(a) = type_aliases.get(c_search_type) {
|
||||||
|
child_relation_alias = a.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql_string = if is_parent_source {
|
||||||
|
format!(
|
||||||
|
"{}.{} = {}.{}",
|
||||||
|
parent_relation_alias, source_col, child_relation_alias, dest_col
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}.{} = {}.{}",
|
||||||
|
child_relation_alias, source_col, parent_relation_alias, dest_col
|
||||||
|
)
|
||||||
|
};
|
||||||
|
where_clauses.push(sql_string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,6 @@ impl Queryer {
|
|||||||
pub fn query(
|
pub fn query(
|
||||||
&self,
|
&self,
|
||||||
schema_id: &str,
|
schema_id: &str,
|
||||||
stem_opt: Option<&str>,
|
|
||||||
filters: Option<&serde_json::Value>,
|
filters: Option<&serde_json::Value>,
|
||||||
) -> crate::drop::Drop {
|
) -> crate::drop::Drop {
|
||||||
let filters_map = filters.and_then(|f| f.as_object());
|
let filters_map = filters.and_then(|f| f.as_object());
|
||||||
@ -32,19 +31,21 @@ impl Queryer {
|
|||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
return crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
return crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||||
code: "FILTER_PARSE_FAILED".to_string(),
|
code: "FILTER_PARSE_FAILED".to_string(),
|
||||||
message: msg,
|
message: msg.clone(),
|
||||||
details: crate::drop::ErrorDetails {
|
details: crate::drop::ErrorDetails {
|
||||||
path: schema_id.to_string(),
|
path: "".to_string(), // filters apply to the root query
|
||||||
|
cause: Some(msg),
|
||||||
|
context: filters.cloned(),
|
||||||
|
schema: Some(schema_id.to_string()),
|
||||||
},
|
},
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let stem_key = stem_opt.unwrap_or("/");
|
let cache_key = format!("{}:{}", schema_id, filter_keys.join(","));
|
||||||
let cache_key = format!("{}(Stem:{}):{}", schema_id, stem_key, filter_keys.join(","));
|
|
||||||
|
|
||||||
// 2. Fetch from cache or compile
|
// 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,
|
Ok(sql) => sql,
|
||||||
Err(drop) => return drop,
|
Err(drop) => return drop,
|
||||||
};
|
};
|
||||||
@ -53,6 +54,45 @@ impl Queryer {
|
|||||||
self.execute_sql(schema_id, &sql, &args)
|
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(
|
fn parse_filter_entries(
|
||||||
&self,
|
&self,
|
||||||
filters_map: Option<&serde_json::Map<String, serde_json::Value>>,
|
filters_map: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||||
@ -60,19 +100,7 @@ impl Queryer {
|
|||||||
let mut filter_entries: Vec<(String, serde_json::Value)> = Vec::new();
|
let mut filter_entries: Vec<(String, serde_json::Value)> = Vec::new();
|
||||||
if let Some(fm) = filters_map {
|
if let Some(fm) = filters_map {
|
||||||
for (key, val) in fm {
|
for (key, val) in fm {
|
||||||
if let Some(obj) = val.as_object() {
|
Self::extract_filters(key.clone(), val, &mut filter_entries)?;
|
||||||
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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
filter_entries.sort_by(|a, b| a.0.cmp(&b.0));
|
filter_entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
@ -87,15 +115,19 @@ impl Queryer {
|
|||||||
&self,
|
&self,
|
||||||
cache_key: &str,
|
cache_key: &str,
|
||||||
schema_id: &str,
|
schema_id: &str,
|
||||||
stem_opt: Option<&str>,
|
|
||||||
filter_keys: &[String],
|
filter_keys: &[String],
|
||||||
) -> Result<String, crate::drop::Drop> {
|
) -> Result<String, crate::drop::Drop> {
|
||||||
if let Some(cached_sql) = self.cache.get(cache_key) {
|
if let Some(cached_sql) = self.cache.get(cache_key) {
|
||||||
return Ok(cached_sql.value().clone());
|
return Ok(cached_sql.value().clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let compiler = compiler::SqlCompiler::new(self.db.clone());
|
let compiler = compiler::Compiler {
|
||||||
match compiler.compile(schema_id, stem_opt, filter_keys) {
|
db: &self.db,
|
||||||
|
filter_keys: filter_keys,
|
||||||
|
alias_counter: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
match compiler.compile(schema_id, filter_keys) {
|
||||||
Ok(compiled_sql) => {
|
Ok(compiled_sql) => {
|
||||||
self
|
self
|
||||||
.cache
|
.cache
|
||||||
@ -104,9 +136,12 @@ impl Queryer {
|
|||||||
}
|
}
|
||||||
Err(e) => Err(crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
Err(e) => Err(crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||||
code: "QUERY_COMPILATION_FAILED".to_string(),
|
code: "QUERY_COMPILATION_FAILED".to_string(),
|
||||||
message: e,
|
message: e.clone(),
|
||||||
details: crate::drop::ErrorDetails {
|
details: crate::drop::ErrorDetails {
|
||||||
path: schema_id.to_string(),
|
path: "".to_string(),
|
||||||
|
cause: Some(e),
|
||||||
|
context: None,
|
||||||
|
schema: Some(schema_id.to_string()),
|
||||||
},
|
},
|
||||||
}])),
|
}])),
|
||||||
}
|
}
|
||||||
@ -130,14 +165,20 @@ impl Queryer {
|
|||||||
code: "QUERY_FAILED".to_string(),
|
code: "QUERY_FAILED".to_string(),
|
||||||
message: format!("Expected array from generic query, got: {:?}", other),
|
message: format!("Expected array from generic query, got: {:?}", other),
|
||||||
details: crate::drop::ErrorDetails {
|
details: crate::drop::ErrorDetails {
|
||||||
path: schema_id.to_string(),
|
path: "".to_string(),
|
||||||
|
cause: Some(format!("Expected array, got {}", other)),
|
||||||
|
context: Some(serde_json::json!([sql])),
|
||||||
|
schema: Some(schema_id.to_string()),
|
||||||
},
|
},
|
||||||
}]),
|
}]),
|
||||||
Err(e) => crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
Err(e) => crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||||
code: "QUERY_FAILED".to_string(),
|
code: "QUERY_FAILED".to_string(),
|
||||||
message: format!("SPI error in queryer: {}", e),
|
message: format!("SPI error in queryer: {}", e),
|
||||||
details: crate::drop::ErrorDetails {
|
details: crate::drop::ErrorDetails {
|
||||||
path: schema_id.to_string(),
|
path: "".to_string(),
|
||||||
|
cause: Some(format!("SPI error in queryer: {}", e)),
|
||||||
|
context: Some(serde_json::json!([sql])),
|
||||||
|
schema: Some(schema_id.to_string()),
|
||||||
},
|
},
|
||||||
}]),
|
}]),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1457,18 +1457,6 @@ fn test_queryer_0_7() {
|
|||||||
crate::tests::runner::run_test_case(&path, 0, 7).unwrap();
|
crate::tests::runner::run_test_case(&path, 0, 7).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_queryer_0_8() {
|
|
||||||
let path = format!("{}/fixtures/queryer.json", env!("CARGO_MANIFEST_DIR"));
|
|
||||||
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]
|
#[test]
|
||||||
fn test_not_0_0() {
|
fn test_not_0_0() {
|
||||||
let path = format!("{}/fixtures/not.json", env!("CARGO_MANIFEST_DIR"));
|
let path = format!("{}/fixtures/not.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
@ -8554,3 +8542,21 @@ fn test_merger_0_7() {
|
|||||||
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
|
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
crate::tests::runner::run_test_case(&path, 0, 7).unwrap();
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ fn test_library_api() {
|
|||||||
// 1. Initially, schemas are not cached.
|
// 1. Initially, schemas are not cached.
|
||||||
|
|
||||||
// Expected uninitialized drop format: errors + null response
|
// 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!(
|
assert_eq!(
|
||||||
uninitialized_drop.0,
|
uninitialized_drop.0,
|
||||||
json!({
|
json!({
|
||||||
@ -27,17 +27,44 @@ fn test_library_api() {
|
|||||||
let db_json = json!({
|
let db_json = json!({
|
||||||
"puncs": [],
|
"puncs": [],
|
||||||
"enums": [],
|
"enums": [],
|
||||||
"relations": [],
|
"relations": [
|
||||||
"types": [{
|
{
|
||||||
|
"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": [{
|
"schemas": [{
|
||||||
"$id": "test_schema",
|
"$id": "source_schema",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string" }
|
"name": { "type": "string" },
|
||||||
|
"target": { "$ref": "target_schema" }
|
||||||
},
|
},
|
||||||
"required": ["name"]
|
"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));
|
let cache_drop = jspg_setup(JsonB(db_json));
|
||||||
@ -49,8 +76,46 @@ fn test_library_api() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 3. Validate jspg_schemas
|
||||||
|
let schemas_drop = jspg_schemas();
|
||||||
|
assert_eq!(
|
||||||
|
schemas_drop.0,
|
||||||
|
json!({
|
||||||
|
"type": "drop",
|
||||||
|
"response": {
|
||||||
|
"source_schema": {
|
||||||
|
"$id": "source_schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"target": {
|
||||||
|
"$ref": "target_schema",
|
||||||
|
"compiledProperties": ["value"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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
|
// 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!(
|
assert_eq!(
|
||||||
happy_drop.0,
|
happy_drop.0,
|
||||||
json!({
|
json!({
|
||||||
@ -60,7 +125,7 @@ fn test_library_api() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 5. Validate Unhappy Path
|
// 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!(
|
assert_eq!(
|
||||||
unhappy_drop.0,
|
unhappy_drop.0,
|
||||||
json!({
|
json!({
|
||||||
|
|||||||
@ -1,19 +1,10 @@
|
|||||||
|
use crate::tests::types::Suite;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::{Arc, OnceLock, RwLock};
|
use std::sync::{Arc, OnceLock, RwLock};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct TestSuite {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub description: String,
|
|
||||||
pub database: serde_json::Value,
|
|
||||||
pub tests: Vec<TestCase>,
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::tests::types::TestCase;
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
pub fn deserialize_some<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
|
pub fn deserialize_some<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
|
||||||
where
|
where
|
||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
@ -23,7 +14,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Type alias for easier reading
|
// Type alias for easier reading
|
||||||
type CompiledSuite = Arc<Vec<(TestSuite, Arc<crate::database::Database>)>>;
|
type CompiledSuite = Arc<Vec<(Suite, Arc<crate::database::Database>)>>;
|
||||||
|
|
||||||
// Global cache mapping filename -> Vector of (Parsed JSON suite, Compiled Database)
|
// Global cache mapping filename -> Vector of (Parsed JSON suite, Compiled Database)
|
||||||
static CACHE: OnceLock<RwLock<HashMap<String, CompiledSuite>>> = OnceLock::new();
|
static CACHE: OnceLock<RwLock<HashMap<String, CompiledSuite>>> = OnceLock::new();
|
||||||
@ -46,7 +37,7 @@ fn get_cached_file(path: &str) -> CompiledSuite {
|
|||||||
} else {
|
} else {
|
||||||
let content =
|
let content =
|
||||||
fs::read_to_string(path).unwrap_or_else(|_| panic!("Failed to read file: {}", path));
|
fs::read_to_string(path).unwrap_or_else(|_| panic!("Failed to read file: {}", path));
|
||||||
let suites: Vec<TestSuite> = serde_json::from_str(&content)
|
let suites: Vec<Suite> = serde_json::from_str(&content)
|
||||||
.unwrap_or_else(|e| panic!("Failed to parse JSON in {}: {}", path, e));
|
.unwrap_or_else(|e| panic!("Failed to parse JSON in {}: {}", path, e));
|
||||||
|
|
||||||
let mut compiled_suites = Vec::new();
|
let mut compiled_suites = Vec::new();
|
||||||
@ -97,6 +88,16 @@ pub fn run_test_case(path: &str, suite_idx: usize, case_idx: usize) -> Result<()
|
|||||||
// 4. Run Tests
|
// 4. Run Tests
|
||||||
|
|
||||||
match test.action.as_str() {
|
match test.action.as_str() {
|
||||||
|
"compile" => {
|
||||||
|
let result = test.run_compile(db.clone());
|
||||||
|
if let Err(e) = result {
|
||||||
|
println!("TEST COMPILE ERROR FOR '{}': {}", test.description, e);
|
||||||
|
failures.push(format!(
|
||||||
|
"[{}] Compile Test '{}' failed. Error: {}",
|
||||||
|
group.description, test.description, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
"validate" => {
|
"validate" => {
|
||||||
let result = test.run_validate(db.clone());
|
let result = test.run_validate(db.clone());
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
use super::expect::ExpectBlock;
|
use super::expect::Expect;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct TestCase {
|
pub struct Case {
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
|
||||||
#[serde(default = "default_action")]
|
#[serde(default = "default_action")]
|
||||||
@ -16,9 +16,6 @@ pub struct TestCase {
|
|||||||
pub schema_id: String,
|
pub schema_id: String,
|
||||||
|
|
||||||
// For Query
|
// For Query
|
||||||
#[serde(default)]
|
|
||||||
pub stem: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub filters: Option<serde_json::Value>,
|
pub filters: Option<serde_json::Value>,
|
||||||
|
|
||||||
@ -30,24 +27,29 @@ pub struct TestCase {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mocks: Option<serde_json::Value>,
|
pub mocks: Option<serde_json::Value>,
|
||||||
|
|
||||||
pub expect: Option<ExpectBlock>,
|
pub expect: Option<Expect>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_action() -> String {
|
fn default_action() -> String {
|
||||||
"validate".to_string()
|
"validate".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestCase {
|
impl Case {
|
||||||
pub fn execute(&self, db: Arc<Database>) -> Result<(), String> {
|
pub fn run_compile(&self, _db: Arc<Database>) -> Result<(), String> {
|
||||||
match self.action.as_str() {
|
let expected_success = self.expect.as_ref().map(|e| e.success).unwrap_or(false);
|
||||||
"validate" => self.run_validate(db),
|
|
||||||
"merge" => self.run_merge(db),
|
// We assume db has already been setup and compiled successfully by runner.rs's `jspg_setup`
|
||||||
"query" => self.run_query(db),
|
// We just need to check if there are compilation errors vs expected success
|
||||||
_ => Err(format!(
|
let got_success = true; // Setup ensures success unless setup fails, which runner handles
|
||||||
"Unknown action '{}' for test '{}'",
|
|
||||||
self.action, self.description
|
if expected_success != got_success {
|
||||||
)),
|
return Err(format!(
|
||||||
|
"Expected success: {}, Got: {}",
|
||||||
|
expected_success, got_success
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_validate(&self, db: Arc<Database>) -> Result<(), String> {
|
pub fn run_validate(&self, db: Arc<Database>) -> Result<(), String> {
|
||||||
@ -97,7 +99,7 @@ impl TestCase {
|
|||||||
let merger = Merger::new(db.clone());
|
let merger = Merger::new(db.clone());
|
||||||
|
|
||||||
let test_data = self.data.clone().unwrap_or(Value::Null);
|
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 expected_success = self.expect.as_ref().map(|e| e.success).unwrap_or(false);
|
||||||
let got_success = result.errors.is_empty();
|
let got_success = result.errors.is_empty();
|
||||||
@ -115,6 +117,7 @@ impl TestCase {
|
|||||||
))
|
))
|
||||||
} else if let Some(expect) = &self.expect {
|
} else if let Some(expect) = &self.expect {
|
||||||
let queries = db.executor.get_queries();
|
let queries = db.executor.get_queries();
|
||||||
|
expect.assert_pattern(&queries)?;
|
||||||
expect.assert_sql(&queries)
|
expect.assert_sql(&queries)
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -134,8 +137,7 @@ impl TestCase {
|
|||||||
use crate::queryer::Queryer;
|
use crate::queryer::Queryer;
|
||||||
let queryer = Queryer::new(db.clone());
|
let queryer = Queryer::new(db.clone());
|
||||||
|
|
||||||
let stem_opt = self.stem.as_deref();
|
let result = queryer.query(&self.schema_id, self.filters.as_ref());
|
||||||
let result = queryer.query(&self.schema_id, stem_opt, self.filters.as_ref());
|
|
||||||
|
|
||||||
let expected_success = self.expect.as_ref().map(|e| e.success).unwrap_or(false);
|
let expected_success = self.expect.as_ref().map(|e| e.success).unwrap_or(false);
|
||||||
let got_success = result.errors.is_empty();
|
let got_success = result.errors.is_empty();
|
||||||
@ -153,6 +155,7 @@ impl TestCase {
|
|||||||
))
|
))
|
||||||
} else if let Some(expect) = &self.expect {
|
} else if let Some(expect) = &self.expect {
|
||||||
let queries = db.executor.get_queries();
|
let queries = db.executor.get_queries();
|
||||||
|
expect.assert_pattern(&queries)?;
|
||||||
expect.assert_sql(&queries)
|
expect.assert_sql(&queries)
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
20
src/tests/types/expect/mod.rs
Normal file
20
src/tests/types/expect/mod.rs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
pub mod pattern;
|
||||||
|
pub mod sql;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum SqlExpectation {
|
||||||
|
Single(String),
|
||||||
|
Multi(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Expect {
|
||||||
|
pub success: bool,
|
||||||
|
pub result: Option<serde_json::Value>,
|
||||||
|
pub errors: Option<Vec<serde_json::Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sql: Option<Vec<SqlExpectation>>,
|
||||||
|
}
|
||||||
@ -1,29 +1,13 @@
|
|||||||
|
use super::Expect;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::Deserialize;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
impl Expect {
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum SqlExpectation {
|
|
||||||
Single(String),
|
|
||||||
Multi(Vec<String>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ExpectBlock {
|
|
||||||
pub success: bool,
|
|
||||||
pub result: Option<serde_json::Value>,
|
|
||||||
pub errors: Option<Vec<serde_json::Value>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub sql: Option<Vec<SqlExpectation>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExpectBlock {
|
|
||||||
/// Advanced SQL execution assertion algorithm ported from `assert.go`.
|
/// Advanced SQL execution assertion algorithm ported from `assert.go`.
|
||||||
/// This compares two arrays of strings, one containing {{uuid:name}} or {{timestamp}} placeholders,
|
/// This compares two arrays of strings, one containing {{uuid:name}} or {{timestamp}} placeholders,
|
||||||
/// and the other containing actual executed database queries. It ensures that placeholder UUIDs
|
/// and the other containing actual executed database queries. It ensures that placeholder UUIDs
|
||||||
/// are consistently mapped to the same actual UUIDs across all lines, and strictly validates line-by-line sequences.
|
/// are consistently mapped to the same actual UUIDs across all lines, and strictly validates line-by-line sequences.
|
||||||
pub fn assert_sql(&self, actual: &[String]) -> Result<(), String> {
|
pub fn assert_pattern(&self, actual: &[String]) -> Result<(), String> {
|
||||||
let patterns = match &self.sql {
|
let patterns = match &self.sql {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
@ -75,8 +59,8 @@ impl ExpectBlock {
|
|||||||
let aline = clean_str(aline_raw);
|
let aline = clean_str(aline_raw);
|
||||||
|
|
||||||
let pattern_str_raw = match pattern_expect {
|
let pattern_str_raw = match pattern_expect {
|
||||||
SqlExpectation::Single(s) => s.clone(),
|
super::SqlExpectation::Single(s) => s.clone(),
|
||||||
SqlExpectation::Multi(m) => m.join(" "),
|
super::SqlExpectation::Multi(m) => m.join(" "),
|
||||||
};
|
};
|
||||||
|
|
||||||
let pattern_str = clean_str(&pattern_str_raw);
|
let pattern_str = clean_str(&pattern_str_raw);
|
||||||
206
src/tests/types/expect/sql.rs
Normal file
206
src/tests/types/expect/sql.rs
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
use super::Expect;
|
||||||
|
use sqlparser::ast::{Expr, Query, SelectItem, Statement, TableFactor};
|
||||||
|
use sqlparser::dialect::PostgreSqlDialect;
|
||||||
|
use sqlparser::parser::Parser;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
impl Expect {
|
||||||
|
pub fn assert_sql(&self, actual: &[String]) -> Result<(), String> {
|
||||||
|
for query in actual {
|
||||||
|
if let Err(e) = Self::validate_semantic_sql(query) {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_semantic_sql(sql: &str) -> Result<(), String> {
|
||||||
|
let dialect = PostgreSqlDialect {};
|
||||||
|
let statements = match Parser::parse_sql(&dialect, sql) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => return Err(format!("SQL Syntax Error: {}\nSQL: {}", e, sql)),
|
||||||
|
};
|
||||||
|
|
||||||
|
for statement in statements {
|
||||||
|
Self::validate_statement(&statement, sql)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_statement(stmt: &Statement, original_sql: &str) -> Result<(), String> {
|
||||||
|
match stmt {
|
||||||
|
Statement::Query(query) => Self::validate_query(query, &HashSet::new(), original_sql)?,
|
||||||
|
Statement::Insert(insert) => {
|
||||||
|
if let Some(query) = &insert.source {
|
||||||
|
Self::validate_query(query, &HashSet::new(), original_sql)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Statement::Update(update) => {
|
||||||
|
if let Some(expr) = &update.selection {
|
||||||
|
Self::validate_expr(expr, &HashSet::new(), original_sql)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Statement::Delete(delete) => {
|
||||||
|
if let Some(expr) = &delete.selection {
|
||||||
|
Self::validate_expr(expr, &HashSet::new(), original_sql)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_query(
|
||||||
|
query: &Query,
|
||||||
|
available_aliases: &HashSet<String>,
|
||||||
|
original_sql: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if let sqlparser::ast::SetExpr::Select(select) = &*query.body {
|
||||||
|
Self::validate_select(&select, available_aliases, original_sql)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_select(
|
||||||
|
select: &sqlparser::ast::Select,
|
||||||
|
parent_aliases: &HashSet<String>,
|
||||||
|
original_sql: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut available_aliases = parent_aliases.clone();
|
||||||
|
|
||||||
|
// 1. Collect all declared table aliases in the FROM clause and JOINs
|
||||||
|
for table_with_joins in &select.from {
|
||||||
|
Self::collect_aliases_from_table_factor(&table_with_joins.relation, &mut available_aliases);
|
||||||
|
for join in &table_with_joins.joins {
|
||||||
|
Self::collect_aliases_from_table_factor(&join.relation, &mut available_aliases);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate all SELECT projection fields
|
||||||
|
for projection in &select.projection {
|
||||||
|
if let SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } = projection {
|
||||||
|
Self::validate_expr(expr, &available_aliases, original_sql)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validate ON conditions in joins
|
||||||
|
for table_with_joins in &select.from {
|
||||||
|
for join in &table_with_joins.joins {
|
||||||
|
if let sqlparser::ast::JoinOperator::Inner(sqlparser::ast::JoinConstraint::On(expr))
|
||||||
|
| sqlparser::ast::JoinOperator::LeftOuter(sqlparser::ast::JoinConstraint::On(expr))
|
||||||
|
| sqlparser::ast::JoinOperator::RightOuter(sqlparser::ast::JoinConstraint::On(expr))
|
||||||
|
| sqlparser::ast::JoinOperator::FullOuter(sqlparser::ast::JoinConstraint::On(expr))
|
||||||
|
| sqlparser::ast::JoinOperator::Join(sqlparser::ast::JoinConstraint::On(expr)) =
|
||||||
|
&join.join_operator
|
||||||
|
{
|
||||||
|
Self::validate_expr(expr, &available_aliases, original_sql)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Validate WHERE conditions
|
||||||
|
if let Some(selection) = &select.selection {
|
||||||
|
Self::validate_expr(selection, &available_aliases, original_sql)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_aliases_from_table_factor(tf: &TableFactor, aliases: &mut HashSet<String>) {
|
||||||
|
match tf {
|
||||||
|
TableFactor::Table { name, alias, .. } => {
|
||||||
|
if let Some(table_alias) = alias {
|
||||||
|
aliases.insert(table_alias.name.value.clone());
|
||||||
|
} else if let Some(last) = name.0.last() {
|
||||||
|
match last {
|
||||||
|
sqlparser::ast::ObjectNamePart::Identifier(i) => {
|
||||||
|
aliases.insert(i.value.clone());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TableFactor::Derived {
|
||||||
|
subquery,
|
||||||
|
alias: Some(table_alias),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
aliases.insert(table_alias.name.value.clone());
|
||||||
|
// A derived table is technically a nested scope which is opaque outside, but for pure semantic checks
|
||||||
|
// its internal contents should be validated purely within its own scope (not leaking external aliases in, usually)
|
||||||
|
// but Postgres allows lateral correlation. We will validate its interior with an empty scope.
|
||||||
|
let _ = Self::validate_query(subquery, &HashSet::new(), "");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_expr(
|
||||||
|
expr: &Expr,
|
||||||
|
available_aliases: &HashSet<String>,
|
||||||
|
sql: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match expr {
|
||||||
|
Expr::CompoundIdentifier(idents) => {
|
||||||
|
if idents.len() == 2 {
|
||||||
|
let alias = &idents[0].value;
|
||||||
|
if !available_aliases.is_empty() && !available_aliases.contains(alias) {
|
||||||
|
return Err(format!(
|
||||||
|
"Semantic Error: Orchestrated query referenced table alias '{}' but it was not declared in the query's FROM/JOIN clauses.\nAvailable aliases: {:?}\nSQL: {}",
|
||||||
|
alias, available_aliases, sql
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if idents.len() > 2 {
|
||||||
|
let alias = &idents[1].value; // In form schema.table.column, 'table' is idents[1]
|
||||||
|
if !available_aliases.is_empty() && !available_aliases.contains(alias) {
|
||||||
|
return Err(format!(
|
||||||
|
"Semantic Error: Orchestrated query referenced table '{}' but it was not mapped.\nAvailable aliases: {:?}\nSQL: {}",
|
||||||
|
alias, available_aliases, sql
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Subquery(subquery) => Self::validate_query(subquery, available_aliases, sql)?,
|
||||||
|
Expr::Exists { subquery, .. } => Self::validate_query(subquery, available_aliases, sql)?,
|
||||||
|
Expr::InSubquery {
|
||||||
|
expr: e, subquery, ..
|
||||||
|
} => {
|
||||||
|
Self::validate_expr(e, available_aliases, sql)?;
|
||||||
|
Self::validate_query(subquery, available_aliases, sql)?;
|
||||||
|
}
|
||||||
|
Expr::BinaryOp { left, right, .. } => {
|
||||||
|
Self::validate_expr(left, available_aliases, sql)?;
|
||||||
|
Self::validate_expr(right, available_aliases, sql)?;
|
||||||
|
}
|
||||||
|
Expr::IsFalse(e)
|
||||||
|
| Expr::IsNotFalse(e)
|
||||||
|
| Expr::IsTrue(e)
|
||||||
|
| Expr::IsNotTrue(e)
|
||||||
|
| Expr::IsNull(e)
|
||||||
|
| Expr::IsNotNull(e)
|
||||||
|
| Expr::InList { expr: e, .. }
|
||||||
|
| Expr::Nested(e)
|
||||||
|
| Expr::UnaryOp { expr: e, .. }
|
||||||
|
| Expr::Cast { expr: e, .. }
|
||||||
|
| Expr::Like { expr: e, .. }
|
||||||
|
| Expr::ILike { expr: e, .. }
|
||||||
|
| Expr::AnyOp { left: e, .. }
|
||||||
|
| Expr::AllOp { left: e, .. } => {
|
||||||
|
Self::validate_expr(e, available_aliases, sql)?;
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
Self::validate_expr(e, available_aliases, sql)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,6 @@ pub mod case;
|
|||||||
pub mod expect;
|
pub mod expect;
|
||||||
pub mod suite;
|
pub mod suite;
|
||||||
|
|
||||||
pub use case::TestCase;
|
pub use case::Case;
|
||||||
pub use expect::ExpectBlock;
|
pub use expect::Expect;
|
||||||
pub use suite::TestSuite;
|
pub use suite::Suite;
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
use super::case::TestCase;
|
use super::case::Case;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct TestSuite {
|
pub struct Suite {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub database: serde_json::Value,
|
pub database: serde_json::Value,
|
||||||
pub tests: Vec<TestCase>,
|
pub tests: Vec<Case>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,7 +67,12 @@ impl Validator {
|
|||||||
.map(|e| crate::drop::Error {
|
.map(|e| crate::drop::Error {
|
||||||
code: e.code,
|
code: e.code,
|
||||||
message: e.message,
|
message: e.message,
|
||||||
details: crate::drop::ErrorDetails { path: e.path },
|
details: crate::drop::ErrorDetails {
|
||||||
|
path: e.path,
|
||||||
|
cause: None,
|
||||||
|
context: None,
|
||||||
|
schema: None,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
crate::drop::Drop::with_errors(errors)
|
crate::drop::Drop::with_errors(errors)
|
||||||
@ -76,7 +81,12 @@ impl Validator {
|
|||||||
Err(e) => crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
Err(e) => crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||||
code: e.code,
|
code: e.code,
|
||||||
message: e.message,
|
message: e.message,
|
||||||
details: crate::drop::ErrorDetails { path: e.path },
|
details: crate::drop::ErrorDetails {
|
||||||
|
path: e.path,
|
||||||
|
cause: None,
|
||||||
|
context: None,
|
||||||
|
schema: None,
|
||||||
|
},
|
||||||
}]),
|
}]),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -84,7 +94,10 @@ impl Validator {
|
|||||||
code: "SCHEMA_NOT_FOUND".to_string(),
|
code: "SCHEMA_NOT_FOUND".to_string(),
|
||||||
message: format!("Schema {} not found", schema_id),
|
message: format!("Schema {} not found", schema_id),
|
||||||
details: crate::drop::ErrorDetails {
|
details: crate::drop::ErrorDetails {
|
||||||
path: "".to_string(),
|
path: "/".to_string(),
|
||||||
|
cause: None,
|
||||||
|
context: None,
|
||||||
|
schema: None,
|
||||||
},
|
},
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ impl<'a> ValidationContext<'a> {
|
|||||||
result: &mut ValidationResult,
|
result: &mut ValidationResult,
|
||||||
) -> Result<bool, ValidationError> {
|
) -> Result<bool, ValidationError> {
|
||||||
let current = self.instance;
|
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 {
|
match compiled_fmt {
|
||||||
crate::database::schema::CompiledFormat::Func(f) => {
|
crate::database::schema::CompiledFormat::Func(f) => {
|
||||||
let should = if let Some(s) = current.as_str() {
|
let should = if let Some(s) = current.as_str() {
|
||||||
|
|||||||
@ -13,13 +13,18 @@ impl<'a> ValidationContext<'a> {
|
|||||||
) -> Result<bool, ValidationError> {
|
) -> Result<bool, ValidationError> {
|
||||||
let current = self.instance;
|
let current = self.instance;
|
||||||
if let Some(obj) = current.as_object() {
|
if let Some(obj) = current.as_object() {
|
||||||
// Entity Bound Implicit Type Validation
|
// Entity implicit type validation
|
||||||
if let Some(lookup_key) = self.schema.id.as_ref().or(self.schema.r#ref.as_ref()) {
|
// Use the specific schema id or ref as a fallback
|
||||||
let base_type_name = lookup_key.split('.').next_back().unwrap_or("").to_string();
|
if let Some(identifier) = self.schema.id.as_ref().or(self.schema.r#ref.as_ref()) {
|
||||||
if let Some(type_def) = self.db.types.get(&base_type_name)
|
// Kick in if the data object has a type field
|
||||||
&& let Some(type_val) = obj.get("type")
|
if let Some(type_val) = obj.get("type")
|
||||||
&& let Some(type_str) = type_val.as_str()
|
&& let Some(type_str) = type_val.as_str()
|
||||||
{
|
{
|
||||||
|
// Get the string or the final segment as the base
|
||||||
|
let base = identifier.split('.').next_back().unwrap_or("").to_string();
|
||||||
|
// Check if the base is a global type name
|
||||||
|
if let Some(type_def) = self.db.types.get(&base) {
|
||||||
|
// Ensure the instance type is a variation of the global type
|
||||||
if type_def.variations.contains(type_str) {
|
if type_def.variations.contains(type_str) {
|
||||||
// Ensure it passes strict mode
|
// Ensure it passes strict mode
|
||||||
result.evaluated_keys.insert("type".to_string());
|
result.evaluated_keys.insert("type".to_string());
|
||||||
@ -33,8 +38,15 @@ impl<'a> ValidationContext<'a> {
|
|||||||
path: format!("{}/type", self.path),
|
path: format!("{}/type", self.path),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Ad-Hoc schemas natively use strict schema discriminator strings instead of variation inheritance
|
||||||
|
if type_str == identifier {
|
||||||
|
result.evaluated_keys.insert("type".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(min) = self.schema.min_properties
|
if let Some(min) = self.schema.min_properties
|
||||||
&& (obj.len() as f64) < min
|
&& (obj.len() as f64) < min
|
||||||
{
|
{
|
||||||
@ -44,6 +56,7 @@ impl<'a> ValidationContext<'a> {
|
|||||||
path: self.path.to_string(),
|
path: self.path.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(max) = self.schema.max_properties
|
if let Some(max) = self.schema.max_properties
|
||||||
&& (obj.len() as f64) > max
|
&& (obj.len() as f64) > max
|
||||||
{
|
{
|
||||||
@ -53,6 +66,7 @@ impl<'a> ValidationContext<'a> {
|
|||||||
path: self.path.to_string(),
|
path: self.path.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref req) = self.schema.required {
|
if let Some(ref req) = self.schema.required {
|
||||||
for field in req {
|
for field in req {
|
||||||
if !obj.contains_key(field) {
|
if !obj.contains_key(field) {
|
||||||
@ -114,10 +128,14 @@ impl<'a> ValidationContext<'a> {
|
|||||||
|
|
||||||
// Entity Bound Implicit Type Interception
|
// Entity Bound Implicit Type Interception
|
||||||
if key == "type"
|
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.id.as_ref().or(sub_schema.r#ref.as_ref())
|
||||||
{
|
{
|
||||||
let base_type_name = lookup_key.split('.').next_back().unwrap_or("").to_string();
|
let physical_type_name = schema_bound
|
||||||
if let Some(type_def) = self.db.types.get(&base_type_name)
|
.split('.')
|
||||||
|
.next_back()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
if let Some(type_def) = self.db.types.get(&physical_type_name)
|
||||||
&& let Some(instance_type) = child_instance.as_str()
|
&& let Some(instance_type) = child_instance.as_str()
|
||||||
&& type_def.variations.contains(instance_type)
|
&& type_def.variations.contains(instance_type)
|
||||||
{
|
{
|
||||||
@ -133,7 +151,7 @@ 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 (compiled_re, sub_schema) in compiled_pp {
|
||||||
for (key, child_instance) in obj {
|
for (key, child_instance) in obj {
|
||||||
if compiled_re.0.is_match(key) {
|
if compiled_re.0.is_match(key) {
|
||||||
@ -165,7 +183,7 @@ impl<'a> ValidationContext<'a> {
|
|||||||
{
|
{
|
||||||
locally_matched = true;
|
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 {
|
for (compiled_re, _) in compiled_pp {
|
||||||
if compiled_re.0.is_match(key) {
|
if compiled_re.0.is_match(key) {
|
||||||
|
|||||||
@ -28,7 +28,7 @@ impl<'a> ValidationContext<'a> {
|
|||||||
path: self.path.to_string(),
|
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) {
|
if !compiled_re.0.is_match(s) {
|
||||||
result.errors.push(ValidationError {
|
result.errors.push(ValidationError {
|
||||||
code: "PATTERN_VIOLATED".to_string(),
|
code: "PATTERN_VIOLATED".to_string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user