Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dfd53e53c | |||
| 532bd8da43 | |||
| 271828ebe9 | |||
| 8c430d42e3 | |||
| 4cc5245336 | |||
| c71e99527d | |||
| 843891f67e | |||
| 8bb7085f76 | |||
| ea03584bbd | |||
| 3736c9d8f0 | |||
| ccca9129b2 | |||
| 333fc69735 | |||
| b0fc6c12ef | |||
| 0d14162ef4 | |||
| b755bc6dbd | |||
| 56775c8c1b | |||
| a32cb3a4da | |||
| 9cefc225fc | |||
| 4874c09fb5 | |||
| 86d49273bc | |||
| 724a9e3e44 | |||
| 5b2feb5ea7 | |||
| 473b087d97 | |||
| 6d6745d95d | |||
| 146efaa2d9 | |||
| d0294eec3f | |||
| 02ab4b6438 | |||
| 2a8b991269 | |||
| ce9c9baac9 | |||
| 3034406706 | |||
| 3d918a1acc | |||
| 1f9b407074 | |||
| 6ea6007d86 | |||
| c129864c89 | |||
| 777fc8bbf8 | |||
| 803d62b2fb |
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1663,6 +1663,7 @@ version = "1.0.149"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@ -6,7 +6,7 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
pgrx = "0.16.1"
|
pgrx = "0.16.1"
|
||||||
serde = { version = "1.0.228", features = ["derive", "rc"] }
|
serde = { version = "1.0.228", features = ["derive", "rc"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = { version = "1.0.149", features = ["preserve_order"] }
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
once_cell = "1.21.3"
|
once_cell = "1.21.3"
|
||||||
ahash = "0.8.12"
|
ahash = "0.8.12"
|
||||||
@ -30,7 +30,7 @@ pgrx-tests = "0.16.1"
|
|||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = { version = "1.0.149", features = ["preserve_order"] }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib", "lib"]
|
crate-type = ["cdylib", "lib"]
|
||||||
|
|||||||
56
GEMINI.md
56
GEMINI.md
@ -179,7 +179,37 @@ In the Punc architecture, filters are automatically synthesized, strongly-typed
|
|||||||
* **Inherited Properties**: Filters automatically inherit all valid database columns from their base type schema, immediately converting them to their respective `.condition` schemas.
|
* **Inherited Properties**: Filters automatically inherit all valid database columns from their base type schema, immediately converting them to their respective `.condition` schemas.
|
||||||
* **Relational Proxies**: If a table has a foreign key to another table, the filter automatically generates a proxy property pointing to the related entity's filter (e.g., the `person` filter automatically gains an `organization` property that points to `organization.filter`), allowing infinitely deep nested queries natively.
|
* **Relational Proxies**: If a table has a foreign key to another table, the filter automatically generates a proxy property pointing to the related entity's filter (e.g., the `person` filter automatically gains an `organization` property that points to `organization.filter`), allowing infinitely deep nested queries natively.
|
||||||
* **Logical Operators (`$and`, `$or`)**: Every filter automatically includes `$and` and `$or` arrays, which recursively accept the exact same filter schema, allowing complex logical grouping.
|
* **Logical Operators (`$and`, `$or`)**: Every filter automatically includes `$and` and `$or` arrays, which recursively accept the exact same filter schema, allowing complex logical grouping.
|
||||||
* **Ad-Hoc Extensions (`ad_hoc`)**: Fields stored purely in JSONB bubbles that lack formal database columns can still be queried using the `ad_hoc` object, which passes standard, unvalidated string conditions.
|
* Ad-Hoc Extensions (`ad_hoc`)**: Fields stored purely in JSONB bubbles that lack formal database columns can still be queried using the `ad_hoc` object, which passes standard, unvalidated string conditions.
|
||||||
|
|
||||||
|
### Trait-Based Schema Composition (`traits` & `include`)
|
||||||
|
Traits are reusable, non-generating schema fragments used to share properties and relationships horizontally across multiple schemas. They do not generate separate Go/Dart classes.
|
||||||
|
|
||||||
|
* **Traits Namespace**: Defined in a sibling `"traits"` block next to `"schemas"` inside table comments:
|
||||||
|
```json
|
||||||
|
"traits": {
|
||||||
|
"emailable": {
|
||||||
|
"properties": {
|
||||||
|
"email_addresses": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "contact", "properties": { "target": { "type": "email_address" } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Include Keyword**: Schemas or traits can use the `"include"` array to compose traits or other schemas:
|
||||||
|
```json
|
||||||
|
"full.person": {
|
||||||
|
"type": "person",
|
||||||
|
"include": ["contactable", "owner"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Resolution and Merging**: During `Database::new()`, includes are resolved and merged at the raw JSON level:
|
||||||
|
* **`properties` / `patternProperties`**: Map keys from the host schema override/shadow included traits.
|
||||||
|
* **`required` / `display`**: Lists are merged and deduped.
|
||||||
|
* **`dependencies`**: Merged by combining and deduping lists.
|
||||||
|
* **Scalars / Arrays / Items**: Host definitions completely override included traits.
|
||||||
|
* The `"include"` keyword is stripped, and `"traits"` maps are omitted from serialization.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -265,6 +295,7 @@ The Queryer transforms Postgres into a pre-compiled Semantic Query Engine, desig
|
|||||||
* **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.
|
* **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 sub-queries for each variation. To ensure safe resolution, the compiler dynamically evaluates correlation boundaries: it attempts standard Relational Edge discovery first. If no explicit relational edge exists (indicating pure Table Inheritance rather than a standard foreign-key graph relationship), it safely invokes a **Table Parity Fallback**. This generates an explicit ID correlation constraint (`AND inner.id = outer.id`), perfectly binding the structural variations back to the parent row to eliminate Cartesian products.
|
* **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 sub-queries for each variation. To ensure safe resolution, the compiler dynamically evaluates correlation boundaries: it attempts standard Relational Edge discovery first. If no explicit relational edge exists (indicating pure Table Inheritance rather than a standard foreign-key graph relationship), it safely invokes a **Table Parity Fallback**. This generates an explicit ID correlation constraint (`AND inner.id = outer.id`), perfectly binding the structural variations back to the parent row to eliminate Cartesian products.
|
||||||
* **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.
|
* **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.
|
||||||
|
* **Polymorphic Relation Type Filtering**: When a relationship maps to a polymorphic target with variations, the Queryer compiles an `IN` clause containing all allowed table variations (e.g., `counterparty_type IN ('bot', 'organization', 'person')`) rather than matching the base type literal, ensuring all polymorphic types are loaded correctly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -285,3 +316,26 @@ JSPG abandons the standard `cargo pgrx test` model in favor of native OS testing
|
|||||||
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.
|
||||||
* 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.
|
* 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.
|
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.
|
||||||
|
|
||||||
|
### SQL Expectation Formatting & Auto-Variablization
|
||||||
|
|
||||||
|
Because JSPG SQL compilation generates large, complex relational statements (often featuring dynamically generated UUIDs or timestamps), manually updating expected SQL strings in the test fixtures is error-prone and tedious. To streamline this, JSPG includes a built-in intelligent test fixture formatter.
|
||||||
|
|
||||||
|
**When to use it:**
|
||||||
|
Whenever you modify the internal SQL generation logic (in the Queryer or Merger) and need to update the expected SQL outputs across the entire test suite.
|
||||||
|
|
||||||
|
**How to run it:**
|
||||||
|
Run the test suite sequentially while passing the `UPDATE_EXPECT=1` environment variable:
|
||||||
|
```bash
|
||||||
|
UPDATE_EXPECT=1 cargo test --test-threads=1
|
||||||
|
```
|
||||||
|
*Note: The `--test-threads=1` flag is strictly required to prevent parallel tests from concurrently overwriting the same JSON fixture files and corrupting them.*
|
||||||
|
|
||||||
|
**How it works (Intelligent Variablization):**
|
||||||
|
The JSPG engine natively generates actual, random UUIDs in memory for records inserted during `merger` tests. To assert relational integrity without hardcoding ephemeral random strings, the formatter utilizes an intelligent variable extraction map:
|
||||||
|
1. **Payload Extraction**: Before evaluating the SQL output, the test runner recursively scans the JSON of the `data` and `mocks` blocks for that specific test case. It maps any physical UUID it finds to its exact JSON path (e.g., `3333...` -> `mocks.0.id`).
|
||||||
|
2. **SQL Canonicalization**: The test runner utilizes `sqlparser` to format the raw engine SQL into pristine, multi-line readable structures.
|
||||||
|
3. **Variable Mapping**: It scans the formatted SQL using regex for UUIDs. If it encounters a UUID matching the payload extraction map, it replaces it with a template tag like `{{uuid:mocks.0.id}}` or `{{uuid:data.customer_id}}`.
|
||||||
|
4. **Generated Fallbacks**: If it encounters a brand-new random UUID that wasn't provided in the inputs (e.g., a newly generated ID for an `INSERT`), it assigns it a sequential tracking variable like `{{uuid:generated_0}}`. Every subsequent appearance of that *exact* same random UUID in the SQL transaction will reuse the `{{uuid:generated_0}}` tag. Timestamps are naturally replaced with `{{timestamp}}`.
|
||||||
|
|
||||||
|
This guarantees the `assert_pattern` execution engine can strictly validate that the exact same ID generated for a parent entity is correctly passed as a foreign key to its children across complex database transactions.
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
# Find our new test
|
|
||||||
test_case = next(t for t in data[0]["tests"] if t["description"] == "Test organization_id syntactic sugar permutations")
|
|
||||||
|
|
||||||
# Fix the first SQL command (INSERT INTO entity for person)
|
|
||||||
sql = test_case["expect"]["sql"][0]
|
|
||||||
sql.remove(" \"organization_id\",")
|
|
||||||
sql.remove(" NULL,")
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "w") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
db = data[0]["database"]
|
|
||||||
|
|
||||||
# Add organization_id to fields and grouped_fields.entity of order, order_line, person
|
|
||||||
for t in db["types"]:
|
|
||||||
if t["name"] in ["order", "order_line", "person"]:
|
|
||||||
if "organization_id" not in t["fields"]:
|
|
||||||
t["fields"].append("organization_id")
|
|
||||||
if "organization_id" not in t["grouped_fields"]["entity"]:
|
|
||||||
t["grouped_fields"]["entity"].append("organization_id")
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "w") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "r") as f:
|
|
||||||
text = f.read()
|
|
||||||
|
|
||||||
# Fix the broken formatting
|
|
||||||
text = text.replace("'{',\n \" {timestamp}\",\n \" }'", "'{{timestamp}}'")
|
|
||||||
text = text.replace("'{',\n \" {uuid}\",\n \" }'", "'{{uuid}}'")
|
|
||||||
text = text.replace("'{',\n \" {uuid:person_id}\",\n \" }'", "'{{uuid:person_id}}'")
|
|
||||||
text = text.replace("'{',\n \" {uuid:order_id}\",\n \" }'", "'{{uuid:order_id}}'")
|
|
||||||
text = text.replace("'{',\n \" {uuid:line1_id}\",\n \" }'", "'{{uuid:line1_id}}'")
|
|
||||||
text = text.replace("'{',\n \" {uuid:line2_id}\",\n \" }'", "'{{uuid:line2_id}}'")
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "w") as f:
|
|
||||||
f.write(text)
|
|
||||||
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
test_case = data[0]["tests"][-1]
|
|
||||||
|
|
||||||
for j, sql_group in enumerate(test_case["expect"]["sql"]):
|
|
||||||
new_group = []
|
|
||||||
i = 0
|
|
||||||
while i < len(sql_group):
|
|
||||||
s = sql_group[i]
|
|
||||||
if s.strip() == "'{":
|
|
||||||
if i + 2 < len(sql_group):
|
|
||||||
next_line = sql_group[i+1].strip()
|
|
||||||
next_next_line = sql_group[i+2].strip()
|
|
||||||
if next_next_line == "}',":
|
|
||||||
# Reconstruct
|
|
||||||
new_group.append(f" '{next_line}',")
|
|
||||||
i += 3
|
|
||||||
continue
|
|
||||||
elif next_next_line == "}'":
|
|
||||||
new_group.append(f" '{next_line}'")
|
|
||||||
i += 3
|
|
||||||
continue
|
|
||||||
new_group.append(s)
|
|
||||||
i += 1
|
|
||||||
test_case["expect"]["sql"][j] = new_group
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "w") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
test_case = data[0]["tests"][-1]
|
|
||||||
|
|
||||||
for j, sql_group in enumerate(test_case["expect"]["sql"]):
|
|
||||||
for i, s in enumerate(sql_group):
|
|
||||||
s = s.replace("'{timestamp}'", "'{{timestamp}}'")
|
|
||||||
s = s.replace("'{uuid}'", "'{{uuid}}'")
|
|
||||||
s = s.replace("'{uuid:person_id}'", "'{{uuid:person_id}}'")
|
|
||||||
s = s.replace("'{uuid:order_id}'", "'{{uuid:order_id}}'")
|
|
||||||
s = s.replace("'{uuid:line1_id}'", "'{{uuid:line1_id}}'")
|
|
||||||
s = s.replace("'{uuid:line2_id}'", "'{{uuid:line2_id}}'")
|
|
||||||
sql_group[i] = s
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "w") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
|
|
||||||
@ -37,6 +37,14 @@
|
|||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"type": "$kind.filter"
|
"type": "$kind.filter"
|
||||||
|
},
|
||||||
|
"conditions": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"new": { "type": "$kind.filter" },
|
||||||
|
"old": { "type": "$kind.filter" },
|
||||||
|
"complete": { "type": "$kind.filter" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -149,7 +157,48 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Valid nested filter payload",
|
||||||
|
"data": {
|
||||||
|
"kind": "person",
|
||||||
|
"conditions": {
|
||||||
|
"new": {
|
||||||
|
"age": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema_id": "search",
|
||||||
|
"action": "validate",
|
||||||
|
"expect": {
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Invalid nested filter payload (fails constraint)",
|
||||||
|
"data": {
|
||||||
|
"kind": "person",
|
||||||
|
"conditions": {
|
||||||
|
"new": {
|
||||||
|
"age": "thirty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema_id": "search",
|
||||||
|
"action": "validate",
|
||||||
|
"expect": {
|
||||||
|
"success": false,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "INVALID_TYPE",
|
||||||
|
"details": {
|
||||||
|
"path": "conditions/new/age"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -70,6 +70,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
},
|
},
|
||||||
|
"uuid_field": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@ -181,6 +185,17 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"uuid.condition": {
|
||||||
|
"type": "condition",
|
||||||
|
"properties": {
|
||||||
|
"$eq": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -197,11 +212,11 @@
|
|||||||
"gender.condition": {
|
"gender.condition": {
|
||||||
"type": "condition",
|
"type": "condition",
|
||||||
"compiledPropertyNames": [
|
"compiledPropertyNames": [
|
||||||
|
"kind",
|
||||||
"$eq",
|
"$eq",
|
||||||
"$ne",
|
"$ne",
|
||||||
"$nof",
|
|
||||||
"$of",
|
"$of",
|
||||||
"kind"
|
"$nof"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"$eq": {
|
"$eq": {
|
||||||
@ -239,29 +254,31 @@
|
|||||||
"person": {},
|
"person": {},
|
||||||
"person.filter": {
|
"person.filter": {
|
||||||
"compiledPropertyNames": [
|
"compiledPropertyNames": [
|
||||||
"$and",
|
"first_name",
|
||||||
"$or",
|
|
||||||
"ad_hoc",
|
|
||||||
"age",
|
"age",
|
||||||
"billing_address",
|
"billing_address",
|
||||||
"birth_date",
|
|
||||||
"first_name",
|
|
||||||
"gender",
|
"gender",
|
||||||
"tags"
|
"birth_date",
|
||||||
|
"uuid_field",
|
||||||
|
"tags",
|
||||||
|
"ad_hoc",
|
||||||
|
"$and",
|
||||||
|
"$or"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"$and": {
|
"$and": {
|
||||||
"items": {
|
"items": {
|
||||||
"compiledPropertyNames": [
|
"compiledPropertyNames": [
|
||||||
"$and",
|
"first_name",
|
||||||
"$or",
|
|
||||||
"ad_hoc",
|
|
||||||
"age",
|
"age",
|
||||||
"billing_address",
|
"billing_address",
|
||||||
"birth_date",
|
|
||||||
"first_name",
|
|
||||||
"gender",
|
"gender",
|
||||||
"tags"
|
"birth_date",
|
||||||
|
"uuid_field",
|
||||||
|
"tags",
|
||||||
|
"ad_hoc",
|
||||||
|
"$and",
|
||||||
|
"$or"
|
||||||
],
|
],
|
||||||
"type": "person.filter"
|
"type": "person.filter"
|
||||||
},
|
},
|
||||||
@ -273,15 +290,16 @@
|
|||||||
"$or": {
|
"$or": {
|
||||||
"items": {
|
"items": {
|
||||||
"compiledPropertyNames": [
|
"compiledPropertyNames": [
|
||||||
"$and",
|
"first_name",
|
||||||
"$or",
|
|
||||||
"ad_hoc",
|
|
||||||
"age",
|
"age",
|
||||||
"billing_address",
|
"billing_address",
|
||||||
"birth_date",
|
|
||||||
"first_name",
|
|
||||||
"gender",
|
"gender",
|
||||||
"tags"
|
"birth_date",
|
||||||
|
"uuid_field",
|
||||||
|
"tags",
|
||||||
|
"ad_hoc",
|
||||||
|
"$and",
|
||||||
|
"$or"
|
||||||
],
|
],
|
||||||
"type": "person.filter"
|
"type": "person.filter"
|
||||||
},
|
},
|
||||||
@ -325,6 +343,12 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"uuid_field": {
|
||||||
|
"type": [
|
||||||
|
"uuid.condition",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"first_name": {
|
"first_name": {
|
||||||
"type": [
|
"type": [
|
||||||
"string.condition",
|
"string.condition",
|
||||||
@ -350,9 +374,9 @@
|
|||||||
"address.filter": {
|
"address.filter": {
|
||||||
"type": "filter",
|
"type": "filter",
|
||||||
"compiledPropertyNames": [
|
"compiledPropertyNames": [
|
||||||
|
"city",
|
||||||
"$and",
|
"$and",
|
||||||
"$or",
|
"$or"
|
||||||
"city"
|
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"$and": {
|
"$and": {
|
||||||
@ -362,9 +386,9 @@
|
|||||||
],
|
],
|
||||||
"items": {
|
"items": {
|
||||||
"compiledPropertyNames": [
|
"compiledPropertyNames": [
|
||||||
|
"city",
|
||||||
"$and",
|
"$and",
|
||||||
"$or",
|
"$or"
|
||||||
"city"
|
|
||||||
],
|
],
|
||||||
"type": "address.filter"
|
"type": "address.filter"
|
||||||
}
|
}
|
||||||
@ -376,9 +400,9 @@
|
|||||||
],
|
],
|
||||||
"items": {
|
"items": {
|
||||||
"compiledPropertyNames": [
|
"compiledPropertyNames": [
|
||||||
|
"city",
|
||||||
"$and",
|
"$and",
|
||||||
"$or",
|
"$or"
|
||||||
"city"
|
|
||||||
],
|
],
|
||||||
"type": "address.filter"
|
"type": "address.filter"
|
||||||
}
|
}
|
||||||
@ -396,15 +420,16 @@
|
|||||||
"string.condition": {},
|
"string.condition": {},
|
||||||
"integer.condition": {},
|
"integer.condition": {},
|
||||||
"date.condition": {},
|
"date.condition": {},
|
||||||
|
"uuid.condition": {},
|
||||||
"search": {},
|
"search": {},
|
||||||
"search.filter": {
|
"search.filter": {
|
||||||
"type": "filter",
|
"type": "filter",
|
||||||
"compiledPropertyNames": [
|
"compiledPropertyNames": [
|
||||||
"$and",
|
|
||||||
"$or",
|
|
||||||
"filter",
|
|
||||||
"kind",
|
"kind",
|
||||||
"name"
|
"name",
|
||||||
|
"filter",
|
||||||
|
"$and",
|
||||||
|
"$or"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"$and": {
|
"$and": {
|
||||||
@ -414,11 +439,11 @@
|
|||||||
],
|
],
|
||||||
"items": {
|
"items": {
|
||||||
"compiledPropertyNames": [
|
"compiledPropertyNames": [
|
||||||
"$and",
|
|
||||||
"$or",
|
|
||||||
"filter",
|
|
||||||
"kind",
|
"kind",
|
||||||
"name"
|
"name",
|
||||||
|
"filter",
|
||||||
|
"$and",
|
||||||
|
"$or"
|
||||||
],
|
],
|
||||||
"type": "search.filter"
|
"type": "search.filter"
|
||||||
}
|
}
|
||||||
@ -430,11 +455,11 @@
|
|||||||
],
|
],
|
||||||
"items": {
|
"items": {
|
||||||
"compiledPropertyNames": [
|
"compiledPropertyNames": [
|
||||||
"$and",
|
|
||||||
"$or",
|
|
||||||
"filter",
|
|
||||||
"kind",
|
"kind",
|
||||||
"name"
|
"name",
|
||||||
|
"filter",
|
||||||
|
"$and",
|
||||||
|
"$or"
|
||||||
],
|
],
|
||||||
"type": "search.filter"
|
"type": "search.filter"
|
||||||
}
|
}
|
||||||
|
|||||||
1885
fixtures/merger.json
1885
fixtures/merger.json
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
191
fixtures/traits.json
Normal file
191
fixtures/traits.json
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"description": "Granular trait composition and list merging",
|
||||||
|
"database": {
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"name": "person",
|
||||||
|
"schemas": {
|
||||||
|
"full.person": {
|
||||||
|
"type": "object",
|
||||||
|
"include": ["emailable", "phonable"],
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"traits": {
|
||||||
|
"emailable": {
|
||||||
|
"properties": {
|
||||||
|
"email": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["email"],
|
||||||
|
"display": ["email"]
|
||||||
|
},
|
||||||
|
"phonable": {
|
||||||
|
"properties": {
|
||||||
|
"phone": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["phone"],
|
||||||
|
"display": ["phone"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"description": "valid person with name, email, and phone passes",
|
||||||
|
"schema_id": "full.person",
|
||||||
|
"action": "validate",
|
||||||
|
"data": {
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"phone": "555-1234"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "missing email fails validation",
|
||||||
|
"schema_id": "full.person",
|
||||||
|
"action": "validate",
|
||||||
|
"data": {
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"phone": "555-1234"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"success": false,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "REQUIRED_FIELD_MISSING",
|
||||||
|
"details": {
|
||||||
|
"path": "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Local property shadowing",
|
||||||
|
"database": {
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"name": "person",
|
||||||
|
"schemas": {
|
||||||
|
"full.person": {
|
||||||
|
"type": "object",
|
||||||
|
"include": ["emailable"],
|
||||||
|
"properties": {
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"traits": {
|
||||||
|
"emailable": {
|
||||||
|
"properties": {
|
||||||
|
"email": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"description": "local maxLength overrides trait properties",
|
||||||
|
"schema_id": "full.person",
|
||||||
|
"action": "validate",
|
||||||
|
"data": {
|
||||||
|
"email": "longerthanfive@example.com"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"success": false,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "MAX_LENGTH_VIOLATED",
|
||||||
|
"details": {
|
||||||
|
"path": "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Missing trait compiler error",
|
||||||
|
"database": {
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"name": "person",
|
||||||
|
"schemas": {
|
||||||
|
"full.person": {
|
||||||
|
"type": "object",
|
||||||
|
"include": ["nonexistent_trait"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"description": "emits TRAIT_NOT_FOUND compile error",
|
||||||
|
"action": "compile",
|
||||||
|
"expect": {
|
||||||
|
"success": false,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "TRAIT_NOT_FOUND"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Circular inclusion compiler error",
|
||||||
|
"database": {
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"name": "person",
|
||||||
|
"schemas": {
|
||||||
|
"full.person": {
|
||||||
|
"type": "object",
|
||||||
|
"include": ["trait_a"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"traits": {
|
||||||
|
"trait_a": {
|
||||||
|
"include": ["trait_b"]
|
||||||
|
},
|
||||||
|
"trait_b": {
|
||||||
|
"include": ["trait_a"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"description": "emits CIRCULAR_INCLUDE_DETECTED compile error",
|
||||||
|
"action": "compile",
|
||||||
|
"expect": {
|
||||||
|
"success": false,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "CIRCULAR_INCLUDE_DETECTED"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
111
format_sql.py
111
format_sql.py
@ -1,111 +0,0 @@
|
|||||||
import json
|
|
||||||
import re
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
test_case = next(t for t in data[0]["tests"] if t["description"] == "Test organization_id syntactic sugar permutations")
|
|
||||||
|
|
||||||
def format_sql(sql_str):
|
|
||||||
if sql_str.startswith("INSERT INTO"):
|
|
||||||
parts = sql_str.split(" VALUES ")
|
|
||||||
insert_part = parts[0]
|
|
||||||
values_part = parts[1]
|
|
||||||
|
|
||||||
insert_match = re.match(r"(INSERT INTO [a-zA-Z0-9_.\"]+) \((.*)\)", insert_part)
|
|
||||||
table = insert_match.group(1)
|
|
||||||
cols_str = insert_match.group(2)
|
|
||||||
cols = [c.strip() for c in cols_str.split(",")]
|
|
||||||
|
|
||||||
values_str = values_part[1:-1]
|
|
||||||
|
|
||||||
# We need to split values_str carefully, as JSON strings contain commas!
|
|
||||||
# Since it's single quotes around values, we can split by ", " but that's risky.
|
|
||||||
# Let's do a simple parse:
|
|
||||||
vals = []
|
|
||||||
current_val = []
|
|
||||||
in_quote = False
|
|
||||||
i = 0
|
|
||||||
while i < len(values_str):
|
|
||||||
c = values_str[i]
|
|
||||||
if c == "'":
|
|
||||||
# handle double quotes inside? Postgres uses '' for escaping ' inside '.
|
|
||||||
# Here we don't have that complexity.
|
|
||||||
in_quote = not in_quote
|
|
||||||
current_val.append(c)
|
|
||||||
elif c == ',' and not in_quote:
|
|
||||||
vals.append("".join(current_val).strip())
|
|
||||||
current_val = []
|
|
||||||
else:
|
|
||||||
current_val.append(c)
|
|
||||||
i += 1
|
|
||||||
vals.append("".join(current_val).strip())
|
|
||||||
|
|
||||||
lines = [f"{table} ("]
|
|
||||||
for i, col in enumerate(cols):
|
|
||||||
lines.append(f" {col}" + ("," if i < len(cols) - 1 else ""))
|
|
||||||
lines.append(")")
|
|
||||||
lines.append("VALUES (")
|
|
||||||
|
|
||||||
for i, val in enumerate(vals):
|
|
||||||
if val.startswith("'{") and val.endswith("}'"):
|
|
||||||
# Format JSON
|
|
||||||
lines.append(" '{")
|
|
||||||
json_str = val[2:-2]
|
|
||||||
# Split json keys by ",
|
|
||||||
json_pairs = json_str.split(',"')
|
|
||||||
for j, pair in enumerate(json_pairs):
|
|
||||||
if j > 0:
|
|
||||||
pair = '"' + pair
|
|
||||||
lines.append(f" {pair}" + ("," if j < len(json_pairs) - 1 else ""))
|
|
||||||
lines.append(" }'" + ("," if i < len(vals) - 1 else ""))
|
|
||||||
else:
|
|
||||||
# Replace '{{uuid}}' with '00000000-0000-0000-0000-000000000000' for created_by etc if it was replaced as '{{uuid}}'
|
|
||||||
if val == "'{{uuid}}'" and cols[i] in ['"created_by"', '"modified_by"', 'modified_by']:
|
|
||||||
val = "'00000000-0000-0000-0000-000000000000'"
|
|
||||||
lines.append(f" {val}" + ("," if i < len(vals) - 1 else ""))
|
|
||||||
lines.append(")")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
elif sql_str.startswith("SELECT pg_notify"):
|
|
||||||
# Format notify string
|
|
||||||
match = re.match(r"SELECT pg_notify\('entity', '(.*)'\)", sql_str)
|
|
||||||
payload = match.group(1)
|
|
||||||
# We know payload looks like {"complete":{...},"new":{...}}
|
|
||||||
lines = ["SELECT pg_notify('entity', '{"]
|
|
||||||
|
|
||||||
# split complete and new
|
|
||||||
complete_str = payload[payload.find('"complete":{')+12:payload.find('},"new":{')]
|
|
||||||
new_str = payload[payload.find('"new":{')+7:-2]
|
|
||||||
|
|
||||||
lines.append(" \"complete\":{")
|
|
||||||
complete_pairs = complete_str.split(',"')
|
|
||||||
for j, pair in enumerate(complete_pairs):
|
|
||||||
if j > 0:
|
|
||||||
pair = '"' + pair
|
|
||||||
lines.append(f" {pair}" + ("," if j < len(complete_pairs) - 1 else ""))
|
|
||||||
lines.append(" },")
|
|
||||||
|
|
||||||
lines.append(" \"new\":{")
|
|
||||||
new_pairs = new_str.split(',"')
|
|
||||||
for j, pair in enumerate(new_pairs):
|
|
||||||
if j > 0:
|
|
||||||
pair = '"' + pair
|
|
||||||
lines.append(f" {pair}" + ("," if j < len(new_pairs) - 1 else ""))
|
|
||||||
lines.append(" }")
|
|
||||||
lines.append(" }')")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
return [sql_str]
|
|
||||||
|
|
||||||
new_sql = []
|
|
||||||
for sql_group in test_case["expect"]["sql"]:
|
|
||||||
sql_str = "".join(sql_group)
|
|
||||||
formatted = format_sql(sql_str)
|
|
||||||
new_sql.append(formatted)
|
|
||||||
|
|
||||||
test_case["expect"]["sql"] = new_sql
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "w") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
|
|
||||||
409
patch_merger.py
409
patch_merger.py
@ -1,409 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
db = data[0]["database"]
|
|
||||||
|
|
||||||
# 1. Update entity schema
|
|
||||||
entity_type = next(t for t in db["types"] if t["name"] == "entity")
|
|
||||||
entity_type["schemas"]["entity"]["properties"]["organization_id"] = {"type": "string"}
|
|
||||||
entity_type["fields"].append("organization_id")
|
|
||||||
entity_type["grouped_fields"]["entity"].append("organization_id")
|
|
||||||
|
|
||||||
# 2. Update person schema
|
|
||||||
person_type = next(t for t in db["types"] if t["name"] == "person")
|
|
||||||
person_type["schemas"]["person"]["properties"]["organization_id"] = {
|
|
||||||
"type": "string",
|
|
||||||
"const": "ffffffff-ffff-ffff-ffff-ffffffffffff"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. Add the test case
|
|
||||||
test_case = {
|
|
||||||
"description": "Test organization_id syntactic sugar permutations",
|
|
||||||
"action": "merge",
|
|
||||||
"data": {
|
|
||||||
"type": "order",
|
|
||||||
"organization_id": "parent-org-id",
|
|
||||||
"customer": {
|
|
||||||
"type": "person",
|
|
||||||
"first_name": "Const",
|
|
||||||
"last_name": "Person"
|
|
||||||
},
|
|
||||||
"lines": [
|
|
||||||
{
|
|
||||||
"type": "order_line"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "order_line",
|
|
||||||
"organization_id": "explicit-org-id"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"schema_id": "order",
|
|
||||||
"expect": {
|
|
||||||
"success": True,
|
|
||||||
"sql": [
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.\"entity\" (",
|
|
||||||
" \"created_at\",",
|
|
||||||
" \"created_by\",",
|
|
||||||
" \"id\",",
|
|
||||||
" \"modified_at\",",
|
|
||||||
" \"modified_by\",",
|
|
||||||
" \"organization_id\",",
|
|
||||||
" \"type\"",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000',",
|
|
||||||
" '{{uuid:person_id}}',",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000',",
|
|
||||||
" NULL,",
|
|
||||||
" 'person'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.\"organization\" (",
|
|
||||||
" \"id\",",
|
|
||||||
" \"type\"",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" '{{uuid:person_id}}',",
|
|
||||||
" 'person'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.\"user\" (",
|
|
||||||
" \"id\",",
|
|
||||||
" \"type\"",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" '{{uuid:person_id}}',",
|
|
||||||
" 'person'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.\"person\" (",
|
|
||||||
" \"first_name\",",
|
|
||||||
" \"id\",",
|
|
||||||
" \"last_name\",",
|
|
||||||
" \"type\"",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" 'Const',",
|
|
||||||
" '{{uuid:person_id}}',",
|
|
||||||
" 'Person',",
|
|
||||||
" 'person'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.change (",
|
|
||||||
" \"old\",",
|
|
||||||
" \"new\",",
|
|
||||||
" entity_id,",
|
|
||||||
" id,",
|
|
||||||
" kind,",
|
|
||||||
" modified_at,",
|
|
||||||
" modified_by",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" NULL,",
|
|
||||||
" '{",
|
|
||||||
" \"first_name\":\"Const\",",
|
|
||||||
" \"last_name\":\"Person\",",
|
|
||||||
" \"type\":\"person\"",
|
|
||||||
" }',",
|
|
||||||
" '{{uuid:person_id}}',",
|
|
||||||
" '{{uuid}}',",
|
|
||||||
" 'create',",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.\"entity\" (",
|
|
||||||
" \"created_at\",",
|
|
||||||
" \"created_by\",",
|
|
||||||
" \"id\",",
|
|
||||||
" \"modified_at\",",
|
|
||||||
" \"modified_by\",",
|
|
||||||
" \"organization_id\",",
|
|
||||||
" \"type\"",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000',",
|
|
||||||
" '{{uuid:line1_id}}',",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000',",
|
|
||||||
" 'parent-org-id',",
|
|
||||||
" 'order_line'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.\"order_line\" (",
|
|
||||||
" \"id\",",
|
|
||||||
" \"order_id\",",
|
|
||||||
" \"type\"",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" '{{uuid:line1_id}}',",
|
|
||||||
" '{{uuid:order_id}}',",
|
|
||||||
" 'order_line'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.change (",
|
|
||||||
" \"old\",",
|
|
||||||
" \"new\",",
|
|
||||||
" entity_id,",
|
|
||||||
" id,",
|
|
||||||
" kind,",
|
|
||||||
" modified_at,",
|
|
||||||
" modified_by",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" NULL,",
|
|
||||||
" '{",
|
|
||||||
" \"order_id\":\"{{uuid:order_id}}\",",
|
|
||||||
" \"organization_id\":\"parent-org-id\",",
|
|
||||||
" \"type\":\"order_line\"",
|
|
||||||
" }',",
|
|
||||||
" '{{uuid:line1_id}}',",
|
|
||||||
" '{{uuid}}',",
|
|
||||||
" 'create',",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.\"entity\" (",
|
|
||||||
" \"created_at\",",
|
|
||||||
" \"created_by\",",
|
|
||||||
" \"id\",",
|
|
||||||
" \"modified_at\",",
|
|
||||||
" \"modified_by\",",
|
|
||||||
" \"organization_id\",",
|
|
||||||
" \"type\"",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000',",
|
|
||||||
" '{{uuid:line2_id}}',",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000',",
|
|
||||||
" 'explicit-org-id',",
|
|
||||||
" 'order_line'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.\"order_line\" (",
|
|
||||||
" \"id\",",
|
|
||||||
" \"order_id\",",
|
|
||||||
" \"type\"",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" '{{uuid:line2_id}}',",
|
|
||||||
" '{{uuid:order_id}}',",
|
|
||||||
" 'order_line'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.change (",
|
|
||||||
" \"old\",",
|
|
||||||
" \"new\",",
|
|
||||||
" entity_id,",
|
|
||||||
" id,",
|
|
||||||
" kind,",
|
|
||||||
" modified_at,",
|
|
||||||
" modified_by",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" NULL,",
|
|
||||||
" '{",
|
|
||||||
" \"order_id\":\"{{uuid:order_id}}\",",
|
|
||||||
" \"organization_id\":\"explicit-org-id\",",
|
|
||||||
" \"type\":\"order_line\"",
|
|
||||||
" }',",
|
|
||||||
" '{{uuid:line2_id}}',",
|
|
||||||
" '{{uuid}}',",
|
|
||||||
" 'create',",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.\"entity\" (",
|
|
||||||
" \"created_at\",",
|
|
||||||
" \"created_by\",",
|
|
||||||
" \"id\",",
|
|
||||||
" \"modified_at\",",
|
|
||||||
" \"modified_by\",",
|
|
||||||
" \"organization_id\",",
|
|
||||||
" \"type\"",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000',",
|
|
||||||
" '{{uuid:order_id}}',",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000',",
|
|
||||||
" 'parent-org-id',",
|
|
||||||
" 'order'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.\"order\" (",
|
|
||||||
" \"customer_id\",",
|
|
||||||
" \"id\",",
|
|
||||||
" \"type\"",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" '{{uuid:person_id}}',",
|
|
||||||
" '{{uuid:order_id}}',",
|
|
||||||
" 'order'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"INSERT INTO agreego.change (",
|
|
||||||
" \"old\",",
|
|
||||||
" \"new\",",
|
|
||||||
" entity_id,",
|
|
||||||
" id,",
|
|
||||||
" kind,",
|
|
||||||
" modified_at,",
|
|
||||||
" modified_by",
|
|
||||||
")",
|
|
||||||
"VALUES (",
|
|
||||||
" NULL,",
|
|
||||||
" '{",
|
|
||||||
" \"customer_id\":\"{{uuid:person_id}}\",",
|
|
||||||
" \"organization_id\":\"parent-org-id\",",
|
|
||||||
" \"type\":\"order\"",
|
|
||||||
" }',",
|
|
||||||
" '{{uuid:order_id}}',",
|
|
||||||
" '{{uuid}}',",
|
|
||||||
" 'create',",
|
|
||||||
" '{{timestamp}}',",
|
|
||||||
" '00000000-0000-0000-0000-000000000000'",
|
|
||||||
")"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"SELECT pg_notify('entity', '{",
|
|
||||||
" \"complete\":{",
|
|
||||||
" \"created_at\":\"{{timestamp}}\",",
|
|
||||||
" \"created_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"first_name\":\"Const\",",
|
|
||||||
" \"id\":\"{{uuid:person_id}}\",",
|
|
||||||
" \"last_name\":\"Person\",",
|
|
||||||
" \"modified_at\":\"{{timestamp}}\",",
|
|
||||||
" \"modified_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"type\":\"person\"",
|
|
||||||
" },",
|
|
||||||
" \"new\":{",
|
|
||||||
" \"first_name\":\"Const\",",
|
|
||||||
" \"last_name\":\"Person\",",
|
|
||||||
" \"type\":\"person\"",
|
|
||||||
" }",
|
|
||||||
" }')"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"SELECT pg_notify('entity', '{",
|
|
||||||
" \"complete\":{",
|
|
||||||
" \"created_at\":\"{{timestamp}}\",",
|
|
||||||
" \"created_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"id\":\"{{uuid:line1_id}}\",",
|
|
||||||
" \"modified_at\":\"{{timestamp}}\",",
|
|
||||||
" \"modified_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"order_id\":\"{{uuid:order_id}}\",",
|
|
||||||
" \"organization_id\":\"parent-org-id\",",
|
|
||||||
" \"type\":\"order_line\"",
|
|
||||||
" },",
|
|
||||||
" \"new\":{",
|
|
||||||
" \"order_id\":\"{{uuid:order_id}}\",",
|
|
||||||
" \"organization_id\":\"parent-org-id\",",
|
|
||||||
" \"type\":\"order_line\"",
|
|
||||||
" }",
|
|
||||||
" }')"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"SELECT pg_notify('entity', '{",
|
|
||||||
" \"complete\":{",
|
|
||||||
" \"created_at\":\"{{timestamp}}\",",
|
|
||||||
" \"created_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"id\":\"{{uuid:line2_id}}\",",
|
|
||||||
" \"modified_at\":\"{{timestamp}}\",",
|
|
||||||
" \"modified_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"order_id\":\"{{uuid:order_id}}\",",
|
|
||||||
" \"organization_id\":\"explicit-org-id\",",
|
|
||||||
" \"type\":\"order_line\"",
|
|
||||||
" },",
|
|
||||||
" \"new\":{",
|
|
||||||
" \"order_id\":\"{{uuid:order_id}}\",",
|
|
||||||
" \"organization_id\":\"explicit-org-id\",",
|
|
||||||
" \"type\":\"order_line\"",
|
|
||||||
" }",
|
|
||||||
" }')"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"SELECT pg_notify('entity', '{",
|
|
||||||
" \"complete\":{",
|
|
||||||
" \"created_at\":\"{{timestamp}}\",",
|
|
||||||
" \"created_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"customer\":{",
|
|
||||||
" \"created_at\":\"{{timestamp}}\",",
|
|
||||||
" \"created_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"first_name\":\"Const\",",
|
|
||||||
" \"id\":\"{{uuid:person_id}}\",",
|
|
||||||
" \"last_name\":\"Person\",",
|
|
||||||
" \"modified_at\":\"{{timestamp}}\",",
|
|
||||||
" \"modified_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"type\":\"person\"",
|
|
||||||
" },",
|
|
||||||
" \"customer_id\":\"{{uuid:person_id}}\",",
|
|
||||||
" \"id\":\"{{uuid:order_id}}\",",
|
|
||||||
" \"lines\":[",
|
|
||||||
" {",
|
|
||||||
" \"created_at\":\"{{timestamp}}\",",
|
|
||||||
" \"created_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"id\":\"{{uuid:line1_id}}\",",
|
|
||||||
" \"modified_at\":\"{{timestamp}}\",",
|
|
||||||
" \"modified_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"order_id\":\"{{uuid:order_id}}\",",
|
|
||||||
" \"organization_id\":\"parent-org-id\",",
|
|
||||||
" \"type\":\"order_line\"",
|
|
||||||
" },",
|
|
||||||
" {",
|
|
||||||
" \"created_at\":\"{{timestamp}}\",",
|
|
||||||
" \"created_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"id\":\"{{uuid:line2_id}}\",",
|
|
||||||
" \"modified_at\":\"{{timestamp}}\",",
|
|
||||||
" \"modified_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"order_id\":\"{{uuid:order_id}}\",",
|
|
||||||
" \"organization_id\":\"explicit-org-id\",",
|
|
||||||
" \"type\":\"order_line\"",
|
|
||||||
" }",
|
|
||||||
" ],",
|
|
||||||
" \"modified_at\":\"{{timestamp}}\",",
|
|
||||||
" \"modified_by\":\"00000000-0000-0000-0000-000000000000\",",
|
|
||||||
" \"organization_id\":\"parent-org-id\",",
|
|
||||||
" \"type\":\"order\"",
|
|
||||||
" },",
|
|
||||||
" \"new\":{",
|
|
||||||
" \"customer_id\":\"{{uuid:person_id}}\",",
|
|
||||||
" \"organization_id\":\"parent-org-id\",",
|
|
||||||
" \"type\":\"order\"",
|
|
||||||
" }",
|
|
||||||
" }')"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data[0]["tests"].append(test_case)
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "w") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
|
|
||||||
12
src/database/action.rs
Normal file
12
src/database/action.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Action {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub punc: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub navigate: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub launch: Option<String>,
|
||||||
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
use crate::database::object::{SchemaObject, SchemaTypeOrArray};
|
use crate::database::object::{SchemaObject, SchemaTypeOrArray};
|
||||||
use crate::database::schema::Schema;
|
use crate::database::schema::Schema;
|
||||||
use crate::database::r#enum::Enum;
|
use crate::database::r#enum::Enum;
|
||||||
use std::collections::BTreeMap;
|
use indexmap::IndexMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
impl Enum {
|
impl Enum {
|
||||||
pub fn compile_condition(&self) -> Schema {
|
pub fn compile_condition(&self) -> Schema {
|
||||||
let mut props = BTreeMap::new();
|
let mut props = IndexMap::new();
|
||||||
let enum_name = &self.name;
|
let enum_name = &self.name;
|
||||||
|
|
||||||
let mut eq_obj = SchemaObject::default();
|
let mut eq_obj = SchemaObject::default();
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use crate::database::schema::Schema;
|
use crate::database::schema::Schema;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
|
||||||
impl Schema {
|
impl Schema {
|
||||||
/// Dynamically infers and compiles all structural database relationships between this Schema
|
/// Dynamically infers and compiles all structural database relationships between this Schema
|
||||||
@ -10,10 +11,10 @@ impl Schema {
|
|||||||
db: &crate::database::Database,
|
db: &crate::database::Database,
|
||||||
root_id: &str,
|
root_id: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
props: &std::collections::BTreeMap<String, std::sync::Arc<Schema>>,
|
props: &IndexMap<String, std::sync::Arc<Schema>>,
|
||||||
errors: &mut Vec<crate::drop::Error>,
|
errors: &mut Vec<crate::drop::Error>,
|
||||||
) -> std::collections::BTreeMap<String, crate::database::edge::Edge> {
|
) -> IndexMap<String, crate::database::edge::Edge> {
|
||||||
let mut schema_edges = std::collections::BTreeMap::new();
|
let mut schema_edges = IndexMap::new();
|
||||||
|
|
||||||
// Determine the physical Database Table Name this schema structurally represents
|
// Determine the physical Database Table Name this schema structurally represents
|
||||||
// Plucks the polymorphic discriminator via dot-notation (e.g. extracting "person" from "full.person")
|
// Plucks the polymorphic discriminator via dot-notation (e.g. extracting "person" from "full.person")
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::database::object::{SchemaObject, SchemaTypeOrArray};
|
use crate::database::object::{SchemaObject, SchemaTypeOrArray};
|
||||||
use crate::database::schema::Schema;
|
use crate::database::schema::Schema;
|
||||||
use std::collections::BTreeMap;
|
use indexmap::IndexMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
impl Schema {
|
impl Schema {
|
||||||
@ -12,7 +12,7 @@ impl Schema {
|
|||||||
_errors: &mut Vec<crate::drop::Error>,
|
_errors: &mut Vec<crate::drop::Error>,
|
||||||
) -> Option<Schema> {
|
) -> Option<Schema> {
|
||||||
if let Some(props) = self.obj.compiled_properties.get() {
|
if let Some(props) = self.obj.compiled_properties.get() {
|
||||||
let mut filter_props = BTreeMap::new();
|
let mut filter_props = IndexMap::new();
|
||||||
for (key, child) in props {
|
for (key, child) in props {
|
||||||
let mut structural_filter = None;
|
let mut structural_filter = None;
|
||||||
|
|
||||||
@ -141,6 +141,8 @@ impl Schema {
|
|||||||
if let Some(fmt) = &schema.obj.format {
|
if let Some(fmt) = &schema.obj.format {
|
||||||
if fmt == "date-time" {
|
if fmt == "date-time" {
|
||||||
return Some(vec!["date.condition".to_string()]);
|
return Some(vec!["date.condition".to_string()]);
|
||||||
|
} else if fmt == "uuid" {
|
||||||
|
return Some(vec!["uuid.condition".to_string()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(vec!["string.condition".to_string()])
|
Some(vec!["string.condition".to_string()])
|
||||||
|
|||||||
@ -5,6 +5,7 @@ pub mod filter;
|
|||||||
pub mod polymorphism;
|
pub mod polymorphism;
|
||||||
|
|
||||||
use crate::database::schema::Schema;
|
use crate::database::schema::Schema;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
|
||||||
impl Schema {
|
impl Schema {
|
||||||
pub fn compile(
|
pub fn compile(
|
||||||
@ -48,7 +49,7 @@ impl Schema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut props = std::collections::BTreeMap::new();
|
let mut props = IndexMap::new();
|
||||||
|
|
||||||
// 1. Resolve INHERITANCE dependencies first
|
// 1. Resolve INHERITANCE dependencies first
|
||||||
if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &self.obj.type_ {
|
if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &self.obj.type_ {
|
||||||
@ -124,8 +125,7 @@ impl Schema {
|
|||||||
|
|
||||||
// 4. Set the OnceLock!
|
// 4. Set the OnceLock!
|
||||||
let _ = self.obj.compiled_properties.set(props.clone());
|
let _ = self.obj.compiled_properties.set(props.clone());
|
||||||
let mut names: Vec<String> = props.keys().cloned().collect();
|
let names: Vec<String> = props.keys().cloned().collect();
|
||||||
names.sort();
|
|
||||||
let _ = self.obj.compiled_property_names.set(names);
|
let _ = self.obj.compiled_property_names.set(names);
|
||||||
|
|
||||||
// 5. Compute Edges natively
|
// 5. Compute Edges natively
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
use indexmap::IndexSet;
|
||||||
use crate::database::schema::Schema;
|
use crate::database::schema::Schema;
|
||||||
|
|
||||||
impl Schema {
|
impl Schema {
|
||||||
@ -8,7 +9,7 @@ impl Schema {
|
|||||||
path: &str,
|
path: &str,
|
||||||
errors: &mut Vec<crate::drop::Error>,
|
errors: &mut Vec<crate::drop::Error>,
|
||||||
) {
|
) {
|
||||||
let mut options = std::collections::BTreeMap::new();
|
let mut options = indexmap::IndexMap::new();
|
||||||
let strategy: &str;
|
let strategy: &str;
|
||||||
|
|
||||||
if let Some(family) = &self.obj.family {
|
if let Some(family) = &self.obj.family {
|
||||||
@ -65,10 +66,10 @@ impl Schema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(one_of) = &self.obj.one_of {
|
} else if let Some(one_of) = &self.obj.one_of {
|
||||||
let mut type_vals = std::collections::HashSet::new();
|
let mut type_vals = IndexSet::new();
|
||||||
let mut kind_vals = std::collections::HashSet::new();
|
let mut kind_vals = IndexSet::new();
|
||||||
let mut disjoint_base = true;
|
let mut disjoint_base = true;
|
||||||
let mut structural_types = std::collections::HashSet::new();
|
let mut structural_types = IndexSet::new();
|
||||||
|
|
||||||
for c in one_of {
|
for c in one_of {
|
||||||
let mut child_id = String::new();
|
let mut child_id = String::new();
|
||||||
|
|||||||
419
src/database/compose/mod.rs
Normal file
419
src/database/compose/mod.rs
Normal file
@ -0,0 +1,419 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
pub fn compose(val: &mut Value, errors: &mut Vec<crate::drop::Error>) -> Result<(), String> {
|
||||||
|
let mut traits = HashMap::new();
|
||||||
|
let mut schemas = HashMap::new();
|
||||||
|
|
||||||
|
// 1. Gather all traits and schemas from enums, types, and puncs arrays
|
||||||
|
let arrays = ["enums", "types", "puncs"];
|
||||||
|
for arr_name in &arrays {
|
||||||
|
if let Some(arr) = val.get(arr_name).and_then(|v| v.as_array()) {
|
||||||
|
for item in arr {
|
||||||
|
if let Some(item_traits) = item.get("traits").and_then(|v| v.as_object()) {
|
||||||
|
for (name, trait_val) in item_traits {
|
||||||
|
traits.insert(name.clone(), trait_val.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(item_schemas) = item.get("schemas").and_then(|v| v.as_object()) {
|
||||||
|
for (name, schema_val) in item_schemas {
|
||||||
|
schemas.insert(name.clone(), schema_val.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Resolve inclusions recursively in all schema objects
|
||||||
|
for arr_name in &arrays {
|
||||||
|
if let Some(arr) = val.get_mut(arr_name).and_then(|v| v.as_array_mut()) {
|
||||||
|
for item in arr {
|
||||||
|
if let Some(item_schemas) = item.get_mut("schemas").and_then(|v| v.as_object_mut()) {
|
||||||
|
for (schema_id, schema_val) in item_schemas {
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
resolve_in_place(
|
||||||
|
schema_val,
|
||||||
|
&traits,
|
||||||
|
&schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
schema_id,
|
||||||
|
&mut visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Strip the "traits" block from each item in enums, types, puncs so it doesn't serialize
|
||||||
|
for arr_name in &arrays {
|
||||||
|
if let Some(arr) = val.get_mut(arr_name).and_then(|v| v.as_array_mut()) {
|
||||||
|
for item in arr {
|
||||||
|
if let Some(obj) = item.as_object_mut() {
|
||||||
|
obj.remove("traits");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_in_place(
|
||||||
|
current: &mut Value,
|
||||||
|
traits: &HashMap<String, Value>,
|
||||||
|
schemas: &HashMap<String, Value>,
|
||||||
|
errors: &mut Vec<crate::drop::Error>,
|
||||||
|
schema_id: &str,
|
||||||
|
path: &str,
|
||||||
|
visited: &mut HashSet<String>,
|
||||||
|
) {
|
||||||
|
if !current.is_object() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let include_opt = current
|
||||||
|
.as_object_mut()
|
||||||
|
.and_then(|obj| obj.remove("include"));
|
||||||
|
if let Some(include_val) = include_opt {
|
||||||
|
if let Some(include_arr) = include_val.as_array() {
|
||||||
|
let mut merged_props = serde_json::Map::new();
|
||||||
|
let mut merged_required = HashSet::new();
|
||||||
|
let mut merged_display = HashSet::new();
|
||||||
|
let mut merged_dependencies = serde_json::Map::new();
|
||||||
|
let mut merged_pattern_props = serde_json::Map::new();
|
||||||
|
|
||||||
|
// Read current values first to let host override included properties
|
||||||
|
if let Some(req) = current.get("required").and_then(|v| v.as_array()) {
|
||||||
|
for r in req {
|
||||||
|
if let Some(s) = r.as_str() {
|
||||||
|
merged_required.insert(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(disp) = current.get("display").and_then(|v| v.as_array()) {
|
||||||
|
for d in disp {
|
||||||
|
if let Some(s) = d.as_str() {
|
||||||
|
merged_display.insert(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(deps) = current.get("dependencies").and_then(|v| v.as_object()) {
|
||||||
|
for (k, v) in deps {
|
||||||
|
merged_dependencies.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(pat_props) = current.get("patternProperties").and_then(|v| v.as_object()) {
|
||||||
|
for (k, v) in pat_props {
|
||||||
|
merged_pattern_props.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(props) = current.get("properties").and_then(|v| v.as_object()) {
|
||||||
|
for (k, v) in props {
|
||||||
|
merged_props.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for inc in include_arr {
|
||||||
|
if let Some(inc_name) = inc.as_str() {
|
||||||
|
if visited.contains(inc_name) {
|
||||||
|
errors.push(crate::drop::Error {
|
||||||
|
code: "CIRCULAR_INCLUDE_DETECTED".to_string(),
|
||||||
|
message: format!("Circular inclusion detected for '{}'", inc_name),
|
||||||
|
details: crate::drop::ErrorDetails {
|
||||||
|
schema: Some(schema_id.to_string()),
|
||||||
|
path: Some(path.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_opt = traits.get(inc_name).or_else(|| schemas.get(inc_name));
|
||||||
|
if let Some(target_val) = target_opt {
|
||||||
|
let mut resolved_target = target_val.clone();
|
||||||
|
visited.insert(inc_name.to_string());
|
||||||
|
resolve_in_place(
|
||||||
|
&mut resolved_target,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/include/{}", path, inc_name),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
visited.remove(inc_name);
|
||||||
|
|
||||||
|
// Merge properties (host overrides trait)
|
||||||
|
if let Some(target_props) = resolved_target
|
||||||
|
.get("properties")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
{
|
||||||
|
for (k, v) in target_props {
|
||||||
|
if !merged_props.contains_key(k) {
|
||||||
|
merged_props.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge patternProperties (host overrides trait)
|
||||||
|
if let Some(target_pat_props) = resolved_target
|
||||||
|
.get("patternProperties")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
{
|
||||||
|
for (k, v) in target_pat_props {
|
||||||
|
if !merged_pattern_props.contains_key(k) {
|
||||||
|
merged_pattern_props.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge required
|
||||||
|
if let Some(target_req) = resolved_target.get("required").and_then(|v| v.as_array()) {
|
||||||
|
for r in target_req {
|
||||||
|
if let Some(s) = r.as_str() {
|
||||||
|
merged_required.insert(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge display
|
||||||
|
if let Some(target_disp) = resolved_target.get("display").and_then(|v| v.as_array()) {
|
||||||
|
for d in target_disp {
|
||||||
|
if let Some(s) = d.as_str() {
|
||||||
|
merged_display.insert(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge dependencies
|
||||||
|
if let Some(target_deps) = resolved_target
|
||||||
|
.get("dependencies")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
{
|
||||||
|
for (dep_prop, dep_val) in target_deps {
|
||||||
|
if let Some(existing_val) = merged_dependencies.get_mut(dep_prop) {
|
||||||
|
if let (Some(arr_existing), Some(arr_target)) =
|
||||||
|
(existing_val.as_array_mut(), dep_val.as_array())
|
||||||
|
{
|
||||||
|
let mut set: HashSet<String> = arr_existing
|
||||||
|
.iter()
|
||||||
|
.filter_map(|x| x.as_str().map(String::from))
|
||||||
|
.collect();
|
||||||
|
for x in arr_target {
|
||||||
|
if let Some(s) = x.as_str() {
|
||||||
|
if set.insert(s.to_string()) {
|
||||||
|
arr_existing.push(Value::String(s.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
merged_dependencies.insert(dep_prop.clone(), dep_val.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inherit other non-merged schemas/scalars if not defined in host (type, items, cases, family, format, etc.)
|
||||||
|
if let Some(obj) = current.as_object_mut() {
|
||||||
|
for (k, v) in resolved_target.as_object().unwrap() {
|
||||||
|
if k != "properties"
|
||||||
|
&& k != "patternProperties"
|
||||||
|
&& k != "required"
|
||||||
|
&& k != "display"
|
||||||
|
&& k != "dependencies"
|
||||||
|
&& k != "include"
|
||||||
|
{
|
||||||
|
if !obj.contains_key(k) {
|
||||||
|
obj.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push(crate::drop::Error {
|
||||||
|
code: "TRAIT_NOT_FOUND".to_string(),
|
||||||
|
message: format!("Trait or schema '{}' not found for inclusion", inc_name),
|
||||||
|
details: crate::drop::ErrorDetails {
|
||||||
|
schema: Some(schema_id.to_string()),
|
||||||
|
path: Some(path.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(obj) = current.as_object_mut() {
|
||||||
|
if !merged_props.is_empty() {
|
||||||
|
obj.insert("properties".to_string(), Value::Object(merged_props));
|
||||||
|
}
|
||||||
|
if !merged_pattern_props.is_empty() {
|
||||||
|
obj.insert(
|
||||||
|
"patternProperties".to_string(),
|
||||||
|
Value::Object(merged_pattern_props),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !merged_required.is_empty() {
|
||||||
|
let mut req_vec: Vec<Value> = merged_required.into_iter().map(Value::String).collect();
|
||||||
|
req_vec.sort_by(|a, b| a.as_str().unwrap().cmp(b.as_str().unwrap()));
|
||||||
|
obj.insert("required".to_string(), Value::Array(req_vec));
|
||||||
|
}
|
||||||
|
if !merged_display.is_empty() {
|
||||||
|
let mut disp_vec: Vec<Value> = merged_display.into_iter().map(Value::String).collect();
|
||||||
|
disp_vec.sort_by(|a, b| a.as_str().unwrap().cmp(b.as_str().unwrap()));
|
||||||
|
obj.insert("display".to_string(), Value::Array(disp_vec));
|
||||||
|
}
|
||||||
|
if !merged_dependencies.is_empty() {
|
||||||
|
obj.insert(
|
||||||
|
"dependencies".to_string(),
|
||||||
|
Value::Object(merged_dependencies),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process children
|
||||||
|
if let Some(obj) = current.as_object_mut() {
|
||||||
|
if let Some(props) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
|
||||||
|
for (k, v) in props {
|
||||||
|
resolve_in_place(
|
||||||
|
v,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/{}", path, k),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(pat_props) = obj
|
||||||
|
.get_mut("patternProperties")
|
||||||
|
.and_then(|v| v.as_object_mut())
|
||||||
|
{
|
||||||
|
for (k, v) in pat_props {
|
||||||
|
resolve_in_place(
|
||||||
|
v,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/{}", path, k),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(items) = obj.get_mut("items") {
|
||||||
|
resolve_in_place(
|
||||||
|
items,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/items", path),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(prefix_items) = obj.get_mut("prefixItems").and_then(|v| v.as_array_mut()) {
|
||||||
|
for (i, v) in prefix_items.iter_mut().enumerate() {
|
||||||
|
resolve_in_place(
|
||||||
|
v,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/prefixItems/{}", path, i),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(additional_props) = obj.get_mut("additionalProperties") {
|
||||||
|
resolve_in_place(
|
||||||
|
additional_props,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/additionalProperties", path),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(one_of) = obj.get_mut("oneOf").and_then(|v| v.as_array_mut()) {
|
||||||
|
for (i, v) in one_of.iter_mut().enumerate() {
|
||||||
|
resolve_in_place(
|
||||||
|
v,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/oneOf/{}", path, i),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(contains) = obj.get_mut("contains") {
|
||||||
|
resolve_in_place(
|
||||||
|
contains,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/contains", path),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(not) = obj.get_mut("not") {
|
||||||
|
resolve_in_place(
|
||||||
|
not,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/not", path),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(cases) = obj.get_mut("cases").and_then(|v| v.as_array_mut()) {
|
||||||
|
for (i, c_val) in cases.iter_mut().enumerate() {
|
||||||
|
if let Some(c_obj) = c_val.as_object_mut() {
|
||||||
|
if let Some(when) = c_obj.get_mut("when") {
|
||||||
|
resolve_in_place(
|
||||||
|
when,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/cases/{}/when", path, i),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(then) = c_obj.get_mut("then") {
|
||||||
|
resolve_in_place(
|
||||||
|
then,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/cases/{}/then", path, i),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(else_) = c_obj.get_mut("else") {
|
||||||
|
resolve_in_place(
|
||||||
|
else_,
|
||||||
|
traits,
|
||||||
|
schemas,
|
||||||
|
errors,
|
||||||
|
schema_id,
|
||||||
|
&format!("{}/cases/{}/else", path, i),
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
use crate::database::schema::Schema;
|
use crate::database::schema::Schema;
|
||||||
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@ -10,5 +11,5 @@ pub struct Enum {
|
|||||||
pub source: String,
|
pub source: String,
|
||||||
pub values: Vec<String>,
|
pub values: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub schemas: std::collections::BTreeMap<String, Arc<Schema>>,
|
pub schemas: IndexMap<String, Arc<Schema>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,6 +85,14 @@ impl DatabaseExecutor for MockExecutor {
|
|||||||
Ok("2026-03-10T00:00:00Z".to_string())
|
Ok("2026-03-10T00:00:00Z".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn auth_origin(&self) -> Result<Option<Value>, String> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn punc_trigger(&self) -> Result<Option<String>, String> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn get_queries(&self) -> Vec<String> {
|
fn get_queries(&self) -> Vec<String> {
|
||||||
MOCK_STATE.with(|state| state.borrow().captured_queries.clone())
|
MOCK_STATE.with(|state| state.borrow().captured_queries.clone())
|
||||||
|
|||||||
@ -20,6 +20,12 @@ pub trait DatabaseExecutor: Send + Sync {
|
|||||||
/// Returns the current transaction timestamp
|
/// Returns the current transaction timestamp
|
||||||
fn timestamp(&self) -> Result<String, String>;
|
fn timestamp(&self) -> Result<String, String>;
|
||||||
|
|
||||||
|
/// Returns the current auth.origin session context if configured
|
||||||
|
fn auth_origin(&self) -> Result<Option<Value>, String>;
|
||||||
|
|
||||||
|
/// Returns the current punc.name session context if configured
|
||||||
|
fn punc_trigger(&self) -> Result<Option<String>, String>;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn get_queries(&self) -> Vec<String>;
|
fn get_queries(&self) -> Vec<String>;
|
||||||
|
|
||||||
|
|||||||
@ -150,4 +150,42 @@ impl DatabaseExecutor for SpiExecutor {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn auth_origin(&self) -> Result<Option<Value>, String> {
|
||||||
|
Spi::connect(|client| {
|
||||||
|
let mut tup_table = client
|
||||||
|
.select(
|
||||||
|
"SELECT NULLIF(current_setting('auth.origin', true), '')::jsonb",
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("SPI Select Error: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(row) = tup_table.next() {
|
||||||
|
if let Ok(Some(jsonb)) = row.get::<pgrx::JsonB>(1) {
|
||||||
|
return Ok(Some(jsonb.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn punc_trigger(&self) -> Result<Option<String>, String> {
|
||||||
|
Spi::connect(|client| {
|
||||||
|
let mut tup_table = client
|
||||||
|
.select(
|
||||||
|
"SELECT NULLIF(current_setting('punc.name', true), '')",
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("SPI Select Error: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(row) = tup_table.next() {
|
||||||
|
if let Ok(val_opt) = row.get::<String>(1) {
|
||||||
|
return Ok(val_opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
pub mod action;
|
||||||
pub mod compile;
|
pub mod compile;
|
||||||
|
pub mod compose;
|
||||||
pub mod edge;
|
pub mod edge;
|
||||||
pub mod r#enum;
|
pub mod r#enum;
|
||||||
pub mod executors;
|
pub mod executors;
|
||||||
@ -23,30 +25,30 @@ 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;
|
use indexmap::IndexMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use r#type::Type;
|
use r#type::Type;
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
pub enums: HashMap<String, Enum>,
|
pub enums: IndexMap<String, Enum>,
|
||||||
pub types: HashMap<String, Type>,
|
pub types: IndexMap<String, Type>,
|
||||||
pub puncs: HashMap<String, Punc>,
|
pub puncs: IndexMap<String, Punc>,
|
||||||
pub relations: HashMap<String, Relation>,
|
pub relations: IndexMap<String, Relation>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub schemas: HashMap<String, Arc<Schema>>,
|
pub schemas: IndexMap<String, Arc<Schema>>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub executor: Box<dyn DatabaseExecutor + Send + Sync>,
|
pub executor: Box<dyn DatabaseExecutor + Send + Sync>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
pub fn new(val: &serde_json::Value) -> (Self, crate::drop::Drop) {
|
pub fn new(mut val: serde_json::Value) -> (Self, crate::drop::Drop) {
|
||||||
let mut db = Self {
|
let mut db = Self {
|
||||||
enums: HashMap::new(),
|
enums: IndexMap::new(),
|
||||||
types: HashMap::new(),
|
types: IndexMap::new(),
|
||||||
relations: HashMap::new(),
|
relations: IndexMap::new(),
|
||||||
puncs: HashMap::new(),
|
puncs: IndexMap::new(),
|
||||||
schemas: HashMap::new(),
|
schemas: IndexMap::new(),
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
executor: Box::new(SpiExecutor::new()),
|
executor: Box::new(SpiExecutor::new()),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -55,101 +57,115 @@ impl Database {
|
|||||||
|
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
if let Some(arr) = val.get("enums").and_then(|v| v.as_array()) {
|
if let Err(e) = compose::compose(&mut val, &mut errors) {
|
||||||
for item in arr {
|
errors.push(crate::drop::Error {
|
||||||
match serde_json::from_value::<Enum>(item.clone()) {
|
code: "COMPOSE_FAILED".to_string(),
|
||||||
Ok(def) => {
|
message: format!("Fatal error during trait composition: {}", e),
|
||||||
db.enums.insert(def.name.clone(), def);
|
details: crate::drop::ErrorDetails::default(),
|
||||||
}
|
});
|
||||||
Err(e) => {
|
|
||||||
let name = item
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
errors.push(crate::drop::Error {
|
|
||||||
code: "DATABASE_ENUM_PARSE_FAILED".to_string(),
|
|
||||||
message: format!("Failed to parse database enum '{}': {}", name, e),
|
|
||||||
details: crate::drop::ErrorDetails {
|
|
||||||
context: Some(serde_json::json!(name)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(arr) = val.get("types").and_then(|v| v.as_array()) {
|
if let serde_json::Value::Object(mut map) = val {
|
||||||
for item in arr {
|
if let Some(serde_json::Value::Array(arr)) = map.remove("enums") {
|
||||||
match serde_json::from_value::<Type>(item.clone()) {
|
for item in arr {
|
||||||
Ok(def) => {
|
let name = item
|
||||||
db.types.insert(def.name.clone(), def);
|
.get("name")
|
||||||
}
|
.and_then(|v| v.as_str())
|
||||||
Err(e) => {
|
.unwrap_or("unknown")
|
||||||
let name = item
|
.to_string();
|
||||||
.get("name")
|
match serde_json::from_value::<Enum>(item) {
|
||||||
.and_then(|v| v.as_str())
|
Ok(def) => {
|
||||||
.unwrap_or("unknown");
|
db.enums.insert(def.name.clone(), def);
|
||||||
errors.push(crate::drop::Error {
|
}
|
||||||
code: "DATABASE_TYPE_PARSE_FAILED".to_string(),
|
Err(e) => {
|
||||||
message: format!("Failed to parse database type '{}': {}", name, e),
|
errors.push(crate::drop::Error {
|
||||||
details: crate::drop::ErrorDetails {
|
code: "DATABASE_ENUM_PARSE_FAILED".to_string(),
|
||||||
context: Some(serde_json::json!(name)),
|
message: format!("Failed to parse database enum '{}': {}", name, e),
|
||||||
..Default::default()
|
details: crate::drop::ErrorDetails {
|
||||||
},
|
context: Some(serde_json::json!(name)),
|
||||||
});
|
..Default::default()
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(arr) = val.get("relations").and_then(|v| v.as_array()) {
|
|
||||||
for item in arr {
|
|
||||||
match serde_json::from_value::<Relation>(item.clone()) {
|
|
||||||
Ok(def) => {
|
|
||||||
if db.types.contains_key(&def.source_type)
|
|
||||||
&& db.types.contains_key(&def.destination_type)
|
|
||||||
{
|
|
||||||
db.relations.insert(def.constraint.clone(), def);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
}
|
||||||
let constraint = item
|
}
|
||||||
.get("constraint")
|
|
||||||
.and_then(|v| v.as_str())
|
if let Some(serde_json::Value::Array(arr)) = map.remove("types") {
|
||||||
.unwrap_or("unknown");
|
for item in arr {
|
||||||
errors.push(crate::drop::Error {
|
let name = item
|
||||||
code: "DATABASE_RELATION_PARSE_FAILED".to_string(),
|
.get("name")
|
||||||
message: format!("Failed to parse database relation '{}': {}", constraint, e),
|
.and_then(|v| v.as_str())
|
||||||
details: crate::drop::ErrorDetails {
|
.unwrap_or("unknown")
|
||||||
context: Some(serde_json::json!(constraint)),
|
.to_string();
|
||||||
..Default::default()
|
match serde_json::from_value::<Type>(item) {
|
||||||
},
|
Ok(def) => {
|
||||||
});
|
db.types.insert(def.name.clone(), def);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors.push(crate::drop::Error {
|
||||||
|
code: "DATABASE_TYPE_PARSE_FAILED".to_string(),
|
||||||
|
message: format!("Failed to parse database type '{}': {}", name, e),
|
||||||
|
details: crate::drop::ErrorDetails {
|
||||||
|
context: Some(serde_json::json!(name)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(arr) = val.get("puncs").and_then(|v| v.as_array()) {
|
if let Some(serde_json::Value::Array(arr)) = map.remove("relations") {
|
||||||
for item in arr {
|
for item in arr {
|
||||||
match serde_json::from_value::<Punc>(item.clone()) {
|
let constraint = item
|
||||||
Ok(def) => {
|
.get("constraint")
|
||||||
db.puncs.insert(def.name.clone(), def);
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
match serde_json::from_value::<Relation>(item) {
|
||||||
|
Ok(def) => {
|
||||||
|
if db.types.contains_key(&def.source_type)
|
||||||
|
&& db.types.contains_key(&def.destination_type)
|
||||||
|
{
|
||||||
|
db.relations.insert(def.constraint.clone(), def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors.push(crate::drop::Error {
|
||||||
|
code: "DATABASE_RELATION_PARSE_FAILED".to_string(),
|
||||||
|
message: format!("Failed to parse database relation '{}': {}", constraint, e),
|
||||||
|
details: crate::drop::ErrorDetails {
|
||||||
|
context: Some(serde_json::json!(constraint)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
}
|
||||||
let name = item
|
}
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
if let Some(serde_json::Value::Array(arr)) = map.remove("puncs") {
|
||||||
.unwrap_or("unknown");
|
for item in arr {
|
||||||
errors.push(crate::drop::Error {
|
let name = item
|
||||||
code: "DATABASE_PUNC_PARSE_FAILED".to_string(),
|
.get("name")
|
||||||
message: format!("Failed to parse database punc '{}': {}", name, e),
|
.and_then(|v| v.as_str())
|
||||||
details: crate::drop::ErrorDetails {
|
.unwrap_or("unknown")
|
||||||
context: Some(serde_json::json!(name)),
|
.to_string();
|
||||||
..Default::default()
|
match serde_json::from_value::<Punc>(item) {
|
||||||
},
|
Ok(def) => {
|
||||||
});
|
db.puncs.insert(def.name.clone(), def);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors.push(crate::drop::Error {
|
||||||
|
code: "DATABASE_PUNC_PARSE_FAILED".to_string(),
|
||||||
|
message: format!("Failed to parse database punc '{}': {}", name, e),
|
||||||
|
details: crate::drop::ErrorDetails {
|
||||||
|
context: Some(serde_json::json!(name)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -190,6 +206,16 @@ impl Database {
|
|||||||
self.executor.timestamp()
|
self.executor.timestamp()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the current auth.origin session context if configured
|
||||||
|
pub fn auth_origin(&self) -> Result<Option<Value>, String> {
|
||||||
|
self.executor.auth_origin()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current punc.name session context if configured
|
||||||
|
pub fn punc_trigger(&self) -> Result<Option<String>, String> {
|
||||||
|
self.executor.punc_trigger()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn compile(&mut self, errors: &mut Vec<crate::drop::Error>) {
|
pub fn compile(&mut self, errors: &mut Vec<crate::drop::Error>) {
|
||||||
// Phase 1: Registration
|
// Phase 1: Registration
|
||||||
self.collect_schemas(errors);
|
self.collect_schemas(errors);
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
|
use crate::database::action::Action;
|
||||||
use crate::database::schema::Schema;
|
use crate::database::schema::Schema;
|
||||||
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
@ -30,10 +31,10 @@ pub struct SchemaObject {
|
|||||||
|
|
||||||
// Object Keywords
|
// Object Keywords
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub properties: Option<BTreeMap<String, Arc<Schema>>>,
|
pub properties: Option<IndexMap<String, Arc<Schema>>>,
|
||||||
#[serde(rename = "patternProperties")]
|
#[serde(rename = "patternProperties")]
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub pattern_properties: Option<BTreeMap<String, Arc<Schema>>>,
|
pub pattern_properties: Option<IndexMap<String, Arc<Schema>>>,
|
||||||
#[serde(rename = "additionalProperties")]
|
#[serde(rename = "additionalProperties")]
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub additional_properties: Option<Arc<Schema>>,
|
pub additional_properties: Option<Arc<Schema>>,
|
||||||
@ -46,7 +47,7 @@ pub struct SchemaObject {
|
|||||||
|
|
||||||
// dependencies can be schema dependencies or property dependencies
|
// dependencies can be schema dependencies or property dependencies
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub dependencies: Option<BTreeMap<String, Dependency>>,
|
pub dependencies: Option<IndexMap<String, Dependency>>,
|
||||||
|
|
||||||
// Array Keywords
|
// Array Keywords
|
||||||
#[serde(rename = "items")]
|
#[serde(rename = "items")]
|
||||||
@ -147,7 +148,7 @@ pub struct SchemaObject {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub control: Option<String>,
|
pub control: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub actions: Option<BTreeMap<String, Action>>,
|
pub actions: Option<IndexMap<String, Action>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub computer: Option<String>,
|
pub computer: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -164,7 +165,7 @@ pub struct SchemaObject {
|
|||||||
|
|
||||||
// Internal structural representation caching active AST Node maps. Unlike the Go framework counterpart, the JSPG implementation DOES natively include ALL ancestral inheritance boundary schemas because it compiles locally against the raw database graph.
|
// Internal structural representation caching active AST Node maps. Unlike the Go framework counterpart, the JSPG implementation DOES natively include ALL ancestral inheritance boundary schemas because it compiles locally against the raw database graph.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub compiled_properties: OnceLock<BTreeMap<String, Arc<Schema>>>,
|
pub compiled_properties: OnceLock<IndexMap<String, Arc<Schema>>>,
|
||||||
|
|
||||||
#[serde(rename = "compiledDiscriminator")]
|
#[serde(rename = "compiledDiscriminator")]
|
||||||
#[serde(skip_deserializing)]
|
#[serde(skip_deserializing)]
|
||||||
@ -176,13 +177,13 @@ pub struct SchemaObject {
|
|||||||
#[serde(skip_deserializing)]
|
#[serde(skip_deserializing)]
|
||||||
#[serde(skip_serializing_if = "crate::database::object::is_once_lock_map_empty")]
|
#[serde(skip_serializing_if = "crate::database::object::is_once_lock_map_empty")]
|
||||||
#[serde(serialize_with = "crate::database::object::serialize_once_lock")]
|
#[serde(serialize_with = "crate::database::object::serialize_once_lock")]
|
||||||
pub compiled_options: OnceLock<BTreeMap<String, (Option<usize>, Option<String>)>>,
|
pub compiled_options: OnceLock<IndexMap<String, (Option<usize>, Option<String>)>>,
|
||||||
|
|
||||||
#[serde(rename = "compiledEdges")]
|
#[serde(rename = "compiledEdges")]
|
||||||
#[serde(skip_deserializing)]
|
#[serde(skip_deserializing)]
|
||||||
#[serde(skip_serializing_if = "crate::database::object::is_once_lock_map_empty")]
|
#[serde(skip_serializing_if = "crate::database::object::is_once_lock_map_empty")]
|
||||||
#[serde(serialize_with = "crate::database::object::serialize_once_lock")]
|
#[serde(serialize_with = "crate::database::object::serialize_once_lock")]
|
||||||
pub compiled_edges: OnceLock<BTreeMap<String, crate::database::edge::Edge>>,
|
pub compiled_edges: OnceLock<IndexMap<String, crate::database::edge::Edge>>,
|
||||||
|
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub compiled_format: OnceLock<CompiledFormat>,
|
pub compiled_format: OnceLock<CompiledFormat>,
|
||||||
@ -219,14 +220,6 @@ pub enum SchemaTypeOrArray {
|
|||||||
Multiple(Vec<String>),
|
Multiple(Vec<String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Action {
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub navigate: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub punc: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum Dependency {
|
pub enum Dependency {
|
||||||
@ -245,7 +238,7 @@ pub fn serialize_once_lock<T: serde::Serialize, S: serde::Serializer>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_once_lock_map_empty<K, V>(lock: &OnceLock<std::collections::BTreeMap<K, V>>) -> bool {
|
pub fn is_once_lock_map_empty<K, V>(lock: &OnceLock<indexmap::IndexMap<K, V>>) -> bool {
|
||||||
lock.get().map_or(true, |m| m.is_empty())
|
lock.get().map_or(true, |m| m.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
use crate::database::action::Action;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@ -22,14 +23,3 @@ pub struct Sidebar {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub priority: Option<i32>,
|
pub priority: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct Action {
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub punc: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub navigate: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub present: Option<String>,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
use crate::database::page::Page;
|
use crate::database::page::Page;
|
||||||
use crate::database::schema::Schema;
|
use crate::database::schema::Schema;
|
||||||
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@ -18,5 +19,5 @@ pub struct Punc {
|
|||||||
pub save: Option<String>,
|
pub save: Option<String>,
|
||||||
pub page: Option<Page>,
|
pub page: Option<Page>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub schemas: std::collections::BTreeMap<String, Arc<Schema>>,
|
pub schemas: IndexMap<String, Arc<Schema>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashSet;
|
use indexmap::{IndexMap, IndexSet};
|
||||||
|
|
||||||
use crate::database::schema::Schema;
|
use crate::database::schema::Schema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -25,7 +25,7 @@ pub struct Type {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hierarchy: Vec<String>,
|
pub hierarchy: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub variations: HashSet<String>,
|
pub variations: IndexSet<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub relationship: bool,
|
pub relationship: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -39,5 +39,5 @@ pub struct Type {
|
|||||||
pub default_fields: Vec<String>,
|
pub default_fields: Vec<String>,
|
||||||
pub field_types: Option<Value>,
|
pub field_types: Option<Value>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub schemas: std::collections::BTreeMap<String, Arc<Schema>>,
|
pub schemas: IndexMap<String, Arc<Schema>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ pub struct Jspg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Jspg {
|
impl Jspg {
|
||||||
pub fn new(database_val: &serde_json::Value) -> (Self, crate::drop::Drop) {
|
pub fn new(database_val: serde_json::Value) -> (Self, crate::drop::Drop) {
|
||||||
let (database_instance, drop) = Database::new(database_val);
|
let (database_instance, drop) = Database::new(database_val);
|
||||||
let database = Arc::new(database_instance);
|
let database = Arc::new(database_instance);
|
||||||
let validator = Validator::new(database.clone());
|
let validator = Validator::new(database.clone());
|
||||||
|
|||||||
31
src/lib.rs
31
src/lib.rs
@ -7,6 +7,9 @@ pg_module_magic!();
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub struct JsonB(pub serde_json::Value);
|
pub struct JsonB(pub serde_json::Value);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub struct Json(pub serde_json::Value);
|
||||||
|
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod drop;
|
pub mod drop;
|
||||||
pub mod jspg;
|
pub mod jspg;
|
||||||
@ -41,8 +44,8 @@ fn jspg_failure() -> JsonB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(test), pg_extern(strict))]
|
#[cfg_attr(not(test), pg_extern(strict))]
|
||||||
pub fn jspg_setup(database: JsonB) -> JsonB {
|
pub fn jspg_setup(database: Json) -> Json {
|
||||||
let (new_jspg, drop) = crate::jspg::Jspg::new(&database.0);
|
let (new_jspg, drop) = crate::jspg::Jspg::new(database.0);
|
||||||
let new_arc = Arc::new(new_jspg);
|
let new_arc = Arc::new(new_jspg);
|
||||||
|
|
||||||
// 3. ATOMIC SWAP
|
// 3. ATOMIC SWAP
|
||||||
@ -51,7 +54,7 @@ pub fn jspg_setup(database: JsonB) -> JsonB {
|
|||||||
*lock = Some(new_arc);
|
*lock = Some(new_arc);
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonB(serde_json::to_value(drop).unwrap())
|
Json(serde_json::to_value(drop).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(test), pg_extern)]
|
#[cfg_attr(not(test), pg_extern)]
|
||||||
@ -71,6 +74,22 @@ pub fn jspg_merge(schema_id: &str, data: JsonB) -> JsonB {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(test), pg_extern)]
|
||||||
|
pub fn jspg_merge_ordered(schema_id: &str, data: Json) -> Json {
|
||||||
|
let engine_opt = {
|
||||||
|
let lock = GLOBAL_JSPG.read().unwrap();
|
||||||
|
lock.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
match engine_opt {
|
||||||
|
Some(engine) => {
|
||||||
|
let drop = engine.merger.merge(schema_id, data.0);
|
||||||
|
Json(serde_json::to_value(drop).unwrap())
|
||||||
|
}
|
||||||
|
None => Json(jspg_failure().0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(test), pg_extern)]
|
#[cfg_attr(not(test), pg_extern)]
|
||||||
pub fn jspg_query(schema_id: &str, filter: Option<JsonB>) -> JsonB {
|
pub fn jspg_query(schema_id: &str, filter: Option<JsonB>) -> JsonB {
|
||||||
let engine_opt = {
|
let engine_opt = {
|
||||||
@ -109,7 +128,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_database() -> JsonB {
|
pub fn jspg_database() -> Json {
|
||||||
let engine_opt = {
|
let engine_opt = {
|
||||||
let lock = GLOBAL_JSPG.read().unwrap();
|
let lock = GLOBAL_JSPG.read().unwrap();
|
||||||
lock.clone()
|
lock.clone()
|
||||||
@ -120,9 +139,9 @@ pub fn jspg_database() -> JsonB {
|
|||||||
let database_json = serde_json::to_value(&engine.database)
|
let database_json = serde_json::to_value(&engine.database)
|
||||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||||
let drop = crate::drop::Drop::success_with_val(database_json);
|
let drop = crate::drop::Drop::success_with_val(database_json);
|
||||||
JsonB(serde_json::to_value(drop).unwrap())
|
Json(serde_json::to_value(drop).unwrap())
|
||||||
}
|
}
|
||||||
None => jspg_failure(),
|
None => Json(jspg_failure().0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,7 +40,7 @@ impl Merger {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = self.merge_internal(target_schema, data, &mut notifications_queue);
|
let result = self.merge_internal(target_schema, data, &mut notifications_queue, None, false);
|
||||||
|
|
||||||
let val_resolved = match result {
|
let val_resolved = match result {
|
||||||
Ok(val) => val,
|
Ok(val) => val,
|
||||||
@ -129,33 +129,16 @@ impl Merger {
|
|||||||
crate::drop::Drop::success_with_val(stripped_val)
|
crate::drop::Drop::success_with_val(stripped_val)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inject_organization_id(
|
|
||||||
relative: &mut serde_json::Map<String, Value>,
|
|
||||||
entity_fields: &serde_json::Map<String, Value>,
|
|
||||||
schema: &Arc<crate::database::schema::Schema>,
|
|
||||||
) {
|
|
||||||
if !relative.contains_key("organization_id") {
|
|
||||||
if let Some(org_id) = entity_fields.get("organization_id") {
|
|
||||||
if let Some(compiled_props) = schema.obj.compiled_properties.get() {
|
|
||||||
if let Some(org_schema) = compiled_props.get("organization_id") {
|
|
||||||
if org_schema.obj.const_.is_some() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
relative.insert("organization_id".to_string(), org_id.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn merge_internal(
|
pub(crate) fn merge_internal(
|
||||||
&self,
|
&self,
|
||||||
mut schema: Arc<crate::database::schema::Schema>,
|
mut schema: Arc<crate::database::schema::Schema>,
|
||||||
data: Value,
|
data: Value,
|
||||||
notifications: &mut Vec<String>,
|
notifications: &mut Vec<String>,
|
||||||
|
parent_org_id: Option<String>,
|
||||||
|
is_child: bool,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
match data {
|
match data {
|
||||||
Value::Array(items) => self.merge_array(schema, items, notifications),
|
Value::Array(items) => self.merge_array(schema, items, notifications, parent_org_id, is_child),
|
||||||
Value::Object(map) => {
|
Value::Object(map) => {
|
||||||
if let Some(options) = schema.obj.compiled_options.get() {
|
if let Some(options) = schema.obj.compiled_options.get() {
|
||||||
if let Some(disc) = schema.obj.compiled_discriminator.get() {
|
if let Some(disc) = schema.obj.compiled_discriminator.get() {
|
||||||
@ -163,9 +146,7 @@ impl Merger {
|
|||||||
if let Some(v) = val {
|
if let Some(v) = val {
|
||||||
if let Some((idx_opt, target_id_opt)) = options.get(v) {
|
if let Some((idx_opt, target_id_opt)) = options.get(v) {
|
||||||
if let Some(target_id) = target_id_opt {
|
if let Some(target_id) = target_id_opt {
|
||||||
if let Some(target_schema) =
|
if let Some(target_schema) = self.db.schemas.get(target_id) {
|
||||||
self.db.schemas.get(target_id)
|
|
||||||
{
|
|
||||||
schema = target_schema.clone();
|
schema = target_schema.clone();
|
||||||
} else {
|
} else {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@ -204,7 +185,7 @@ impl Merger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.merge_object(schema, map, notifications)
|
self.merge_object(schema, map, notifications, parent_org_id, is_child)
|
||||||
}
|
}
|
||||||
_ => 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()),
|
||||||
}
|
}
|
||||||
@ -215,6 +196,8 @@ impl Merger {
|
|||||||
schema: Arc<crate::database::schema::Schema>,
|
schema: Arc<crate::database::schema::Schema>,
|
||||||
items: Vec<Value>,
|
items: Vec<Value>,
|
||||||
notifications: &mut Vec<String>,
|
notifications: &mut Vec<String>,
|
||||||
|
parent_org_id: Option<String>,
|
||||||
|
is_child: bool,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
let mut item_schema = schema.clone();
|
let mut item_schema = schema.clone();
|
||||||
if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &schema.obj.type_ {
|
if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &schema.obj.type_ {
|
||||||
@ -227,7 +210,7 @@ impl Merger {
|
|||||||
|
|
||||||
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_schema.clone(), item, notifications)?;
|
let resolved = self.merge_internal(item_schema.clone(), item, notifications, parent_org_id.clone(), is_child)?;
|
||||||
resolved_items.push(resolved);
|
resolved_items.push(resolved);
|
||||||
}
|
}
|
||||||
Ok(Value::Array(resolved_items))
|
Ok(Value::Array(resolved_items))
|
||||||
@ -238,6 +221,8 @@ impl Merger {
|
|||||||
schema: Arc<crate::database::schema::Schema>,
|
schema: Arc<crate::database::schema::Schema>,
|
||||||
obj: serde_json::Map<String, Value>,
|
obj: serde_json::Map<String, Value>,
|
||||||
notifications: &mut Vec<String>,
|
notifications: &mut Vec<String>,
|
||||||
|
parent_org_id: Option<String>,
|
||||||
|
is_child: bool,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
let queue_start = notifications.len();
|
let queue_start = notifications.len();
|
||||||
|
|
||||||
@ -297,6 +282,20 @@ impl Merger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut current_org_id = None;
|
||||||
|
if let Some(compiled_props) = schema.obj.compiled_properties.get() {
|
||||||
|
if let Some(org_schema) = compiled_props.get("organization_id") {
|
||||||
|
if let Some(c) = &org_schema.obj.const_ {
|
||||||
|
if let Some(c_str) = c.as_str() {
|
||||||
|
current_org_id = Some(c_str.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current_org_id.is_none() {
|
||||||
|
current_org_id = parent_org_id.clone();
|
||||||
|
}
|
||||||
|
|
||||||
let user_id = self.db.auth_user_id()?;
|
let user_id = self.db.auth_user_id()?;
|
||||||
let timestamp = self.db.timestamp()?;
|
let timestamp = self.db.timestamp()?;
|
||||||
|
|
||||||
@ -311,6 +310,16 @@ impl Merger {
|
|||||||
entity_change_kind = kind;
|
entity_change_kind = kind;
|
||||||
entity_fetched = fetched;
|
entity_fetched = fetched;
|
||||||
entity_replaces = replaces;
|
entity_replaces = replaces;
|
||||||
|
|
||||||
|
if entity_change_kind.as_deref() == Some("create") {
|
||||||
|
if is_child {
|
||||||
|
if !entity_fields.contains_key("organization_id") {
|
||||||
|
if let Some(ref org_id) = current_org_id {
|
||||||
|
entity_fields.insert("organization_id".to_string(), Value::String(org_id.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut entity_response = serde_json::Map::new();
|
let mut entity_response = serde_json::Map::new();
|
||||||
@ -331,13 +340,14 @@ impl Merger {
|
|||||||
if let Some(relation) = self.db.relations.get(&edge.constraint) {
|
if let Some(relation) = self.db.relations.get(&edge.constraint) {
|
||||||
let parent_is_source = edge.forward;
|
let parent_is_source = edge.forward;
|
||||||
|
|
||||||
|
let org_id_to_pass = entity_fields.get("organization_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
if parent_is_source {
|
if parent_is_source {
|
||||||
Self::inject_organization_id(&mut relative, &entity_fields, &rel_schema);
|
|
||||||
|
|
||||||
let mut merged_relative = match self.merge_internal(
|
let mut merged_relative = match self.merge_internal(
|
||||||
rel_schema.clone(),
|
rel_schema.clone(),
|
||||||
Value::Object(relative),
|
Value::Object(relative),
|
||||||
notifications,
|
notifications,
|
||||||
|
org_id_to_pass.clone(),
|
||||||
|
true,
|
||||||
)? {
|
)? {
|
||||||
Value::Object(m) => m,
|
Value::Object(m) => m,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
@ -353,8 +363,6 @@ impl Merger {
|
|||||||
);
|
);
|
||||||
entity_response.insert(relation_name, Value::Object(merged_relative));
|
entity_response.insert(relation_name, Value::Object(merged_relative));
|
||||||
} else {
|
} else {
|
||||||
Self::inject_organization_id(&mut relative, &entity_fields, &rel_schema);
|
|
||||||
|
|
||||||
Self::apply_entity_relation(
|
Self::apply_entity_relation(
|
||||||
&mut relative,
|
&mut relative,
|
||||||
&relation.source_columns,
|
&relation.source_columns,
|
||||||
@ -366,6 +374,8 @@ impl Merger {
|
|||||||
rel_schema.clone(),
|
rel_schema.clone(),
|
||||||
Value::Object(relative),
|
Value::Object(relative),
|
||||||
notifications,
|
notifications,
|
||||||
|
org_id_to_pass.clone(),
|
||||||
|
true,
|
||||||
)? {
|
)? {
|
||||||
Value::Object(m) => m,
|
Value::Object(m) => m,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
@ -385,6 +395,16 @@ impl Merger {
|
|||||||
entity_change_kind = kind;
|
entity_change_kind = kind;
|
||||||
entity_fetched = fetched;
|
entity_fetched = fetched;
|
||||||
entity_replaces = replaces;
|
entity_replaces = replaces;
|
||||||
|
|
||||||
|
if entity_change_kind.as_deref() == Some("create") {
|
||||||
|
if is_child {
|
||||||
|
if !entity_fields.contains_key("organization_id") {
|
||||||
|
if let Some(ref org_id) = current_org_id {
|
||||||
|
entity_fields.insert("organization_id".to_string(), Value::String(org_id.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.merge_entity_fields(
|
self.merge_entity_fields(
|
||||||
@ -423,11 +443,10 @@ impl Merger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let org_id_to_pass = entity_fields.get("organization_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
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 {
|
||||||
Self::inject_organization_id(&mut relative_item, &entity_fields, &item_schema);
|
|
||||||
|
|
||||||
Self::apply_entity_relation(
|
Self::apply_entity_relation(
|
||||||
&mut relative_item,
|
&mut relative_item,
|
||||||
&relation.source_columns,
|
&relation.source_columns,
|
||||||
@ -439,6 +458,8 @@ impl Merger {
|
|||||||
item_schema.clone(),
|
item_schema.clone(),
|
||||||
Value::Object(relative_item),
|
Value::Object(relative_item),
|
||||||
notifications,
|
notifications,
|
||||||
|
org_id_to_pass.clone(),
|
||||||
|
true,
|
||||||
)? {
|
)? {
|
||||||
Value::Object(m) => m,
|
Value::Object(m) => m,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
@ -925,7 +946,25 @@ impl Merger {
|
|||||||
Value::Object(old_vals)
|
Value::Object(old_vals)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let origin = match self.db.auth_origin() {
|
||||||
|
Ok(Some(orig)) => orig,
|
||||||
|
_ => serde_json::json!({
|
||||||
|
"kind": "user",
|
||||||
|
"user_id": user_id
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let trigger = match self.db.punc_trigger() {
|
||||||
|
Ok(Some(trig)) => trig,
|
||||||
|
_ => "merge_entity".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let entity_type_name = type_name.as_str().unwrap_or(&type_obj.name);
|
||||||
|
|
||||||
let mut notification = serde_json::Map::new();
|
let mut notification = serde_json::Map::new();
|
||||||
|
notification.insert("type".to_string(), Value::String(entity_type_name.to_string()));
|
||||||
|
notification.insert("trigger".to_string(), Value::String(trigger));
|
||||||
|
notification.insert("origin".to_string(), origin.clone());
|
||||||
notification.insert("complete".to_string(), Value::Object(complete));
|
notification.insert("complete".to_string(), Value::Object(complete));
|
||||||
notification.insert("new".to_string(), new_val_obj.clone());
|
notification.insert("new".to_string(), new_val_obj.clone());
|
||||||
|
|
||||||
@ -940,14 +979,16 @@ impl Merger {
|
|||||||
let mut notify_sql = None;
|
let mut notify_sql = None;
|
||||||
if type_obj.historical && change_kind != "replace" {
|
if type_obj.historical && change_kind != "replace" {
|
||||||
let change_sql = format!(
|
let change_sql = format!(
|
||||||
"INSERT INTO agreego.change (\"old\", \"new\", entity_id, id, kind, modified_at, modified_by) VALUES ({}, {}, {}, {}, {}, {}, {})",
|
"INSERT INTO agreego.change (\"old\", \"new\", entity_id, id, kind, modified_at, modified_by, origin, entity_type) VALUES ({}, {}, {}, {}, {}, {}, {}, {}, {})",
|
||||||
Self::quote_literal(&old_val_obj),
|
Self::quote_literal(&old_val_obj),
|
||||||
Self::quote_literal(&new_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())),
|
||||||
Self::quote_literal(&Value::String(timestamp.to_string())),
|
Self::quote_literal(&Value::String(timestamp.to_string())),
|
||||||
Self::quote_literal(&Value::String(user_id.to_string()))
|
Self::quote_literal(&Value::String(user_id.to_string())),
|
||||||
|
Self::quote_literal(&origin),
|
||||||
|
Self::quote_literal(&Value::String(entity_type_name.to_string()))
|
||||||
);
|
);
|
||||||
|
|
||||||
self.db.execute(&change_sql, None)?;
|
self.db.execute(&change_sql, None)?;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
|
use indexmap::IndexMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct Compiler<'a> {
|
pub struct Compiler<'a> {
|
||||||
@ -212,6 +213,7 @@ impl<'a> Compiler<'a> {
|
|||||||
|
|
||||||
let mut case_node = node.clone();
|
let mut case_node = node.clone();
|
||||||
case_node.parent_alias = base_alias.clone();
|
case_node.parent_alias = base_alias.clone();
|
||||||
|
case_node.property_name = None;
|
||||||
let arc_aliases = std::sync::Arc::new(table_aliases.clone());
|
let arc_aliases = std::sync::Arc::new(table_aliases.clone());
|
||||||
case_node.parent_type_aliases = Some(arc_aliases);
|
case_node.parent_type_aliases = Some(arc_aliases);
|
||||||
case_node.parent_type = Some(r#type);
|
case_node.parent_type = Some(r#type);
|
||||||
@ -256,7 +258,7 @@ impl<'a> Compiler<'a> {
|
|||||||
|
|
||||||
fn compile_object(
|
fn compile_object(
|
||||||
&mut self,
|
&mut self,
|
||||||
props: &std::collections::BTreeMap<String, std::sync::Arc<crate::database::schema::Schema>>,
|
props: &IndexMap<String, std::sync::Arc<crate::database::schema::Schema>>,
|
||||||
node: Node<'a>,
|
node: Node<'a>,
|
||||||
) -> Result<(String, String), String> {
|
) -> Result<(String, String), String> {
|
||||||
let mut build_args = Vec::new();
|
let mut build_args = Vec::new();
|
||||||
@ -377,10 +379,7 @@ impl<'a> Compiler<'a> {
|
|||||||
return Ok(("NULL".to_string(), "string".to_string()));
|
return Ok(("NULL".to_string(), "string".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
case_statements.sort();
|
|
||||||
|
|
||||||
let sql = format!("CASE {} ELSE NULL END", case_statements.join(" "));
|
let sql = format!("CASE {} ELSE NULL END", case_statements.join(" "));
|
||||||
|
|
||||||
Ok((sql, "object".to_string()))
|
Ok((sql, "object".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,7 +416,7 @@ impl<'a> Compiler<'a> {
|
|||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, String> {
|
||||||
let mut select_args = Vec::new();
|
let mut select_args = Vec::new();
|
||||||
let grouped_fields = r#type.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 default_props = std::collections::BTreeMap::new();
|
let default_props = IndexMap::new();
|
||||||
let merged_props = node
|
let merged_props = node
|
||||||
.schema
|
.schema
|
||||||
.obj
|
.obj
|
||||||
@ -604,7 +603,7 @@ impl<'a> Compiler<'a> {
|
|||||||
|
|
||||||
if let Some(type_name) = bound_type_name {
|
if let Some(type_name) = bound_type_name {
|
||||||
// Ensure this type actually exists
|
// Ensure this type actually exists
|
||||||
if self.db.types.contains_key(&type_name) {
|
if let Some(type_def) = self.db.types.get(&type_name) {
|
||||||
if let Some(relation) = self.db.relations.get(&edge.constraint) {
|
if let Some(relation) = self.db.relations.get(&edge.constraint) {
|
||||||
let mut poly_col = None;
|
let mut poly_col = None;
|
||||||
let mut table_to_alias = "";
|
let mut table_to_alias = "";
|
||||||
@ -622,7 +621,21 @@ impl<'a> Compiler<'a> {
|
|||||||
.get(table_to_alias)
|
.get(table_to_alias)
|
||||||
.or_else(|| type_aliases.get(&node.parent_alias))
|
.or_else(|| type_aliases.get(&node.parent_alias))
|
||||||
{
|
{
|
||||||
where_clauses.push(format!("{}.{} = '{}'", alias, col, type_name));
|
if type_def.variations.len() > 1 {
|
||||||
|
let quoted: Vec<String> = type_def
|
||||||
|
.variations
|
||||||
|
.iter()
|
||||||
|
.map(|v| format!("'{}'", v))
|
||||||
|
.collect();
|
||||||
|
where_clauses.push(format!(
|
||||||
|
"{}.{} IN ({})",
|
||||||
|
alias,
|
||||||
|
col,
|
||||||
|
quoted.join(", ")
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
where_clauses.push(format!("{}.{} = '{}'", alias, col, type_name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1277,6 +1277,18 @@ fn test_dynamic_type_0_4() {
|
|||||||
crate::tests::runner::run_test_case(&path, 0, 4).unwrap();
|
crate::tests::runner::run_test_case(&path, 0, 4).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dynamic_type_0_5() {
|
||||||
|
let path = format!("{}/fixtures/dynamicType.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
crate::tests::runner::run_test_case(&path, 0, 5).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dynamic_type_0_6() {
|
||||||
|
let path = format!("{}/fixtures/dynamicType.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
crate::tests::runner::run_test_case(&path, 0, 6).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_property_names_0_0() {
|
fn test_property_names_0_0() {
|
||||||
let path = format!("{}/fixtures/propertyNames.json", env!("CARGO_MANIFEST_DIR"));
|
let path = format!("{}/fixtures/propertyNames.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
@ -1499,6 +1511,12 @@ fn test_queryer_0_14() {
|
|||||||
crate::tests::runner::run_test_case(&path, 0, 14).unwrap();
|
crate::tests::runner::run_test_case(&path, 0, 14).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_queryer_0_15() {
|
||||||
|
let path = format!("{}/fixtures/queryer.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
crate::tests::runner::run_test_case(&path, 0, 15).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_polymorphism_0_0() {
|
fn test_polymorphism_0_0() {
|
||||||
let path = format!("{}/fixtures/polymorphism.json", env!("CARGO_MANIFEST_DIR"));
|
let path = format!("{}/fixtures/polymorphism.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
@ -2141,6 +2159,36 @@ fn test_items_15_2() {
|
|||||||
crate::tests::runner::run_test_case(&path, 15, 2).unwrap();
|
crate::tests::runner::run_test_case(&path, 15, 2).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_traits_0_0() {
|
||||||
|
let path = format!("{}/fixtures/traits.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
crate::tests::runner::run_test_case(&path, 0, 0).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_traits_0_1() {
|
||||||
|
let path = format!("{}/fixtures/traits.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
crate::tests::runner::run_test_case(&path, 0, 1).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_traits_1_0() {
|
||||||
|
let path = format!("{}/fixtures/traits.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
crate::tests::runner::run_test_case(&path, 1, 0).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_traits_2_0() {
|
||||||
|
let path = format!("{}/fixtures/traits.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
crate::tests::runner::run_test_case(&path, 2, 0).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_traits_3_0() {
|
||||||
|
let path = format!("{}/fixtures/traits.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
crate::tests::runner::run_test_case(&path, 3, 0).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_enum_0_0() {
|
fn test_enum_0_0() {
|
||||||
let path = format!("{}/fixtures/enum.json", env!("CARGO_MANIFEST_DIR"));
|
let path = format!("{}/fixtures/enum.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
|||||||
393
src/tests/formatter.rs
Normal file
393
src/tests/formatter.rs
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
use sqlparser::ast::{
|
||||||
|
BinaryOperator, Expr, Function, FunctionArg, Join, JoinConstraint, JoinOperator,
|
||||||
|
Query, Select, SelectItem, SetExpr, Statement, TableWithJoins, Value
|
||||||
|
};
|
||||||
|
use sqlparser::dialect::PostgreSqlDialect;
|
||||||
|
use sqlparser::parser::Parser;
|
||||||
|
|
||||||
|
pub struct SqlFormatter {
|
||||||
|
pub lines: Vec<String>,
|
||||||
|
pub indent: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlFormatter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
lines: Vec::new(),
|
||||||
|
indent: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format(sql: &str) -> Vec<String> {
|
||||||
|
let dialect = PostgreSqlDialect {};
|
||||||
|
let ast = match Parser::parse_sql(&dialect, sql) {
|
||||||
|
Ok(ast) => ast,
|
||||||
|
Err(e) => {
|
||||||
|
println!("DEBUG PARSE SQL ERROR: {:?}", e);
|
||||||
|
return vec![sql.to_string()];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if ast.is_empty() {
|
||||||
|
return vec![sql.to_string()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut formatter = SqlFormatter::new();
|
||||||
|
formatter.format_statement(&ast[0]);
|
||||||
|
formatter.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_str(&mut self, s: &str) {
|
||||||
|
if self.lines.is_empty() {
|
||||||
|
self.lines.push(format!("{}{}", " ".repeat(self.indent), s.replace("JSONB", "jsonb")));
|
||||||
|
} else {
|
||||||
|
let last = self.lines.last_mut().unwrap();
|
||||||
|
last.push_str(&s.replace("JSONB", "jsonb"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_line(&mut self, s: &str) {
|
||||||
|
self.lines.push(format!("{}{}", " ".repeat(self.indent), s.replace("JSONB", "jsonb")));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_statement(&mut self, stmt: &Statement) {
|
||||||
|
match stmt {
|
||||||
|
Statement::Query(query) => {
|
||||||
|
self.push_line("(");
|
||||||
|
self.format_query(query);
|
||||||
|
self.push_str(")");
|
||||||
|
}
|
||||||
|
Statement::Update(_update) => {
|
||||||
|
let sql = stmt.to_string();
|
||||||
|
self.format_update_fallback(&sql);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let sql = stmt.to_string();
|
||||||
|
if sql.starts_with("INSERT") {
|
||||||
|
self.format_insert_fallback(&sql);
|
||||||
|
} else {
|
||||||
|
self.push_line(&sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_insert_fallback(&mut self, sql: &str) {
|
||||||
|
let s = sql.to_string();
|
||||||
|
if let Some(values_idx) = s.find(" VALUES (") {
|
||||||
|
let prefix = &s[..values_idx];
|
||||||
|
let suffix = &s[values_idx + 9..];
|
||||||
|
|
||||||
|
if let Some(paren_idx) = prefix.find(" (") {
|
||||||
|
self.push_line(&format!("{} (", &prefix[..paren_idx]));
|
||||||
|
self.indent += 2;
|
||||||
|
let cols = &prefix[paren_idx + 2..prefix.len() - 1];
|
||||||
|
let cols_split: Vec<&str> = cols.split(", ").collect();
|
||||||
|
for (i, col) in cols_split.iter().enumerate() {
|
||||||
|
let comma = if i < cols_split.len() - 1 { "," } else { "" };
|
||||||
|
let c = col.replace("\"", "");
|
||||||
|
self.push_line(&format!("\"{}\"{}", c, comma));
|
||||||
|
}
|
||||||
|
self.indent -= 2;
|
||||||
|
self.push_line(")");
|
||||||
|
} else {
|
||||||
|
self.push_line(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.push_line("VALUES (");
|
||||||
|
self.indent += 2;
|
||||||
|
|
||||||
|
let vals = if suffix.ends_with(")") { &suffix[..suffix.len() - 1] } else { suffix };
|
||||||
|
let mut val_tokens = Vec::new();
|
||||||
|
let mut curr = String::new();
|
||||||
|
let mut in_str = false;
|
||||||
|
for c in vals.chars() {
|
||||||
|
if c == '\'' {
|
||||||
|
in_str = !in_str;
|
||||||
|
curr.push(c);
|
||||||
|
} else if c == ',' && !in_str {
|
||||||
|
val_tokens.push(curr.trim().to_string());
|
||||||
|
curr = String::new();
|
||||||
|
} else {
|
||||||
|
curr.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !curr.trim().is_empty() {
|
||||||
|
val_tokens.push(curr.trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, val) in val_tokens.iter().enumerate() {
|
||||||
|
let comma = if i < val_tokens.len() - 1 { "," } else { "" };
|
||||||
|
|
||||||
|
if val.starts_with("'{") && val.ends_with("}'") {
|
||||||
|
let inner = &val[1..val.len() - 1];
|
||||||
|
// Unescape single quotes from SQL strings
|
||||||
|
let unescaped = inner.replace("''", "'");
|
||||||
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&unescaped) {
|
||||||
|
if let Ok(pretty) = serde_json::to_string_pretty(&json) {
|
||||||
|
let lines: Vec<&str> = pretty.split('\n').collect();
|
||||||
|
self.push_line("'{");
|
||||||
|
self.indent += 2;
|
||||||
|
for (j, line) in lines.iter().skip(1).enumerate() {
|
||||||
|
if j == lines.len() - 2 {
|
||||||
|
self.indent -= 2;
|
||||||
|
// re-escape single quotes for SQL
|
||||||
|
self.push_line(&format!("{}'{}", line.replace("'", "''"), comma));
|
||||||
|
} else {
|
||||||
|
self.push_line(&line.replace("'", "''"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.push_line(&format!("{}{}", val, comma));
|
||||||
|
}
|
||||||
|
self.indent -= 2;
|
||||||
|
self.push_line(")");
|
||||||
|
} else {
|
||||||
|
self.push_line(&s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_update_fallback(&mut self, sql: &str) {
|
||||||
|
let s = sql.to_string();
|
||||||
|
if let Some(set_idx) = s.find(" SET ") {
|
||||||
|
self.push_line(&format!("{} SET", &s[..set_idx]));
|
||||||
|
self.indent += 2;
|
||||||
|
|
||||||
|
let after_set = &s[set_idx + 5..];
|
||||||
|
let where_idx = after_set.find(" WHERE ");
|
||||||
|
let assigns = if let Some(w) = where_idx { &after_set[..w] } else { after_set };
|
||||||
|
let assigns_split: Vec<&str> = assigns.split(", ").collect();
|
||||||
|
for (i, assign) in assigns_split.iter().enumerate() {
|
||||||
|
let comma = if i < assigns_split.len() - 1 { "," } else { "" };
|
||||||
|
self.push_line(&format!("{}{}", assign.replace("\"", ""), comma));
|
||||||
|
}
|
||||||
|
self.indent -= 2;
|
||||||
|
|
||||||
|
if let Some(w) = where_idx {
|
||||||
|
self.push_line("WHERE");
|
||||||
|
self.indent += 2;
|
||||||
|
self.push_line(&after_set[w + 7..]);
|
||||||
|
self.indent -= 2;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.push_line(&s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_query(&mut self, query: &Query) {
|
||||||
|
match &*query.body {
|
||||||
|
SetExpr::Select(select) => self.format_select(select),
|
||||||
|
SetExpr::Query(inner_query) => {
|
||||||
|
self.push_str("(");
|
||||||
|
self.format_query(inner_query);
|
||||||
|
self.push_str(")");
|
||||||
|
}
|
||||||
|
_ => self.push_str(&query.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_select(&mut self, select: &Select) {
|
||||||
|
self.push_str("SELECT ");
|
||||||
|
for (i, p) in select.projection.iter().enumerate() {
|
||||||
|
let comma = if i < select.projection.len() - 1 { ", " } else { "" };
|
||||||
|
self.format_select_item(p);
|
||||||
|
self.push_str(comma);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !select.from.is_empty() {
|
||||||
|
self.push_line("FROM ");
|
||||||
|
for (i, table) in select.from.iter().enumerate() {
|
||||||
|
let comma = if i < select.from.len() - 1 { ", " } else { "" };
|
||||||
|
self.format_table_with_joins(table);
|
||||||
|
self.push_str(comma);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(selection) = &select.selection {
|
||||||
|
self.push_line("WHERE");
|
||||||
|
self.indent += 2;
|
||||||
|
self.push_line(""); // new line for where clauses
|
||||||
|
self.format_expr(selection);
|
||||||
|
self.indent -= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_select_item(&mut self, item: &SelectItem) {
|
||||||
|
match item {
|
||||||
|
SelectItem::UnnamedExpr(expr) => self.format_expr(expr),
|
||||||
|
SelectItem::ExprWithAlias { expr, alias } => {
|
||||||
|
self.format_expr(expr);
|
||||||
|
self.push_str(&format!(" AS {}", alias));
|
||||||
|
}
|
||||||
|
_ => self.push_str(&item.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_table_with_joins(&mut self, table: &TableWithJoins) {
|
||||||
|
self.push_str(&table.relation.to_string());
|
||||||
|
for join in &table.joins {
|
||||||
|
self.push_line("");
|
||||||
|
self.format_join(join);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_join(&mut self, join: &Join) {
|
||||||
|
let op = match &join.join_operator {
|
||||||
|
JoinOperator::Inner(_) => "JOIN",
|
||||||
|
JoinOperator::LeftOuter(_) => "LEFT JOIN",
|
||||||
|
_ => "JOIN",
|
||||||
|
};
|
||||||
|
self.push_str(&format!("{} {} ON ", op, join.relation));
|
||||||
|
|
||||||
|
match &join.join_operator {
|
||||||
|
JoinOperator::Inner(JoinConstraint::On(expr)) => self.format_expr(expr),
|
||||||
|
JoinOperator::LeftOuter(JoinConstraint::On(expr)) => self.format_expr(expr),
|
||||||
|
JoinOperator::Join(JoinConstraint::On(expr)) => self.format_expr(expr),
|
||||||
|
_ => {
|
||||||
|
println!("FALLBACK JOIN OP: {:?}", join.join_operator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_expr(&mut self, expr: &Expr) {
|
||||||
|
match expr {
|
||||||
|
Expr::Function(func) => self.format_function(func),
|
||||||
|
Expr::BinaryOp { left, op, right } => {
|
||||||
|
if *op == BinaryOperator::And || *op == BinaryOperator::Or {
|
||||||
|
self.format_expr(left);
|
||||||
|
self.push_line(&format!("{} ", op));
|
||||||
|
self.format_expr(right);
|
||||||
|
} else {
|
||||||
|
self.format_expr(left);
|
||||||
|
self.push_str(&format!(" {} ", op));
|
||||||
|
self.format_expr(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Nested(inner) => {
|
||||||
|
self.push_str("(");
|
||||||
|
self.format_expr(inner);
|
||||||
|
self.push_str(")");
|
||||||
|
}
|
||||||
|
Expr::IsNull(inner) => {
|
||||||
|
self.format_expr(inner);
|
||||||
|
self.push_str(" IS NULL");
|
||||||
|
}
|
||||||
|
Expr::IsNotNull(inner) => {
|
||||||
|
self.format_expr(inner);
|
||||||
|
self.push_str(" IS NOT NULL");
|
||||||
|
}
|
||||||
|
Expr::Subquery(query) => {
|
||||||
|
self.push_str("(");
|
||||||
|
self.indent += 2;
|
||||||
|
self.push_line("");
|
||||||
|
self.format_query(query);
|
||||||
|
self.indent -= 2;
|
||||||
|
self.push_line(")");
|
||||||
|
}
|
||||||
|
Expr::Case { operand, conditions, else_result, .. } => {
|
||||||
|
self.push_str("CASE");
|
||||||
|
if let Some(op) = operand {
|
||||||
|
self.push_str(" ");
|
||||||
|
self.format_expr(op);
|
||||||
|
}
|
||||||
|
self.indent += 2;
|
||||||
|
for when in conditions {
|
||||||
|
self.push_line("WHEN ");
|
||||||
|
self.format_expr(&when.condition);
|
||||||
|
self.push_str(" THEN ");
|
||||||
|
self.format_expr(&when.result);
|
||||||
|
}
|
||||||
|
if let Some(els) = else_result {
|
||||||
|
self.push_line("ELSE ");
|
||||||
|
self.format_expr(els);
|
||||||
|
}
|
||||||
|
self.indent -= 2;
|
||||||
|
self.push_line("END");
|
||||||
|
}
|
||||||
|
Expr::UnaryOp { op, expr: inner } => {
|
||||||
|
self.push_str(&format!("{} ", op));
|
||||||
|
self.format_expr(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::Value(sqlparser::ast::ValueWithSpan { value: Value::SingleQuotedString(s), .. }) | Expr::Value(sqlparser::ast::ValueWithSpan { value: Value::EscapedStringLiteral(s), .. }) => {
|
||||||
|
if s.starts_with('{') && s.ends_with('}') {
|
||||||
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(s) {
|
||||||
|
if let Ok(pretty) = serde_json::to_string_pretty(&json) {
|
||||||
|
let lines: Vec<&str> = pretty.split('\n').collect();
|
||||||
|
self.push_str("'{");
|
||||||
|
self.indent += 2;
|
||||||
|
for (j, line) in lines.iter().skip(1).enumerate() {
|
||||||
|
if j == lines.len() - 2 {
|
||||||
|
self.indent -= 2;
|
||||||
|
self.push_line(&format!("{}'", line.replace("'", "''")));
|
||||||
|
} else {
|
||||||
|
self.push_line(&line.replace("'", "''"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.push_str(&expr.to_string());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.push_str(&expr.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_function(&mut self, func: &Function) {
|
||||||
|
let name = func.name.to_string();
|
||||||
|
self.push_str(&format!("{}(", name));
|
||||||
|
|
||||||
|
if let sqlparser::ast::FunctionArguments::List(list) = &func.args {
|
||||||
|
if name == "jsonb_build_object" {
|
||||||
|
self.indent += 2;
|
||||||
|
self.push_line("");
|
||||||
|
let mut i = 0;
|
||||||
|
while i < list.args.len() {
|
||||||
|
let arg_key = &list.args[i];
|
||||||
|
let arg_val = if i + 1 < list.args.len() { Some(&list.args[i+1]) } else { None };
|
||||||
|
|
||||||
|
self.format_function_arg(arg_key);
|
||||||
|
self.push_str(", ");
|
||||||
|
if let Some(val) = arg_val {
|
||||||
|
self.format_function_arg(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
if i + 2 < list.args.len() {
|
||||||
|
self.push_str(",");
|
||||||
|
self.push_line("");
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
self.indent -= 2;
|
||||||
|
self.push_line(")");
|
||||||
|
} else {
|
||||||
|
for (i, arg) in list.args.iter().enumerate() {
|
||||||
|
let comma = if i < list.args.len() - 1 { ", " } else { "" };
|
||||||
|
self.format_function_arg(arg);
|
||||||
|
self.push_str(comma);
|
||||||
|
}
|
||||||
|
self.push_str(")");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.push_str(")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_function_arg(&mut self, arg: &FunctionArg) {
|
||||||
|
match arg {
|
||||||
|
FunctionArg::Unnamed(sqlparser::ast::FunctionArgExpr::Expr(expr)) => self.format_expr(expr),
|
||||||
|
_ => {
|
||||||
|
println!("FALLBACK ARG: {:?}", arg);
|
||||||
|
self.push_str(&arg.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
|
pub mod formatter;
|
||||||
pub mod runner;
|
pub mod runner;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@ -72,7 +73,7 @@ fn test_library_api() {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
let cache_drop = jspg_setup(JsonB(db_json));
|
let cache_drop = jspg_setup(Json(db_json));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cache_drop.0,
|
cache_drop.0,
|
||||||
json!({
|
json!({
|
||||||
@ -127,7 +128,7 @@ fn test_library_api() {
|
|||||||
"forward": true
|
"forward": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compiledPropertyNames": ["name", "target", "type"],
|
"compiledPropertyNames": ["type", "name", "target"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string" },
|
"name": { "type": "string" },
|
||||||
"target": {
|
"target": {
|
||||||
@ -140,19 +141,19 @@ fn test_library_api() {
|
|||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"source_schema.filter": {
|
"source_schema.filter": {
|
||||||
"compiledPropertyNames": ["$and", "$or", "name", "target", "type"],
|
"compiledPropertyNames": ["type", "name", "target", "$and", "$or"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"$and": {
|
"$and": {
|
||||||
"type": ["array", "null"],
|
"type": ["array", "null"],
|
||||||
"items": {
|
"items": {
|
||||||
"compiledPropertyNames": ["$and", "$or", "name", "target", "type"],
|
"compiledPropertyNames": ["type", "name", "target", "$and", "$or"],
|
||||||
"type": "source_schema.filter"
|
"type": "source_schema.filter"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"$or": {
|
"$or": {
|
||||||
"type": ["array", "null"],
|
"type": ["array", "null"],
|
||||||
"items": {
|
"items": {
|
||||||
"compiledPropertyNames": ["$and", "$or", "name", "target", "type"],
|
"compiledPropertyNames": ["type", "name", "target", "$and", "$or"],
|
||||||
"type": "source_schema.filter"
|
"type": "source_schema.filter"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -193,19 +194,19 @@ fn test_library_api() {
|
|||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"target_schema.filter": {
|
"target_schema.filter": {
|
||||||
"compiledPropertyNames": ["$and", "$or", "value"],
|
"compiledPropertyNames": ["value", "$and", "$or"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"$and": {
|
"$and": {
|
||||||
"type": ["array", "null"],
|
"type": ["array", "null"],
|
||||||
"items": {
|
"items": {
|
||||||
"compiledPropertyNames": ["$and", "$or", "value"],
|
"compiledPropertyNames": ["value", "$and", "$or"],
|
||||||
"type": "target_schema.filter"
|
"type": "target_schema.filter"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"$or": {
|
"$or": {
|
||||||
"type": ["array", "null"],
|
"type": ["array", "null"],
|
||||||
"items": {
|
"items": {
|
||||||
"compiledPropertyNames": ["$and", "$or", "value"],
|
"compiledPropertyNames": ["value", "$and", "$or"],
|
||||||
"type": "target_schema.filter"
|
"type": "target_schema.filter"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -225,7 +226,10 @@ fn test_library_api() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 4. Validate Happy Path
|
// 4. Validate Happy Path
|
||||||
let happy_drop = jspg_validate("source_schema", JsonB(json!({"type": "source_schema", "name": "Neo"})));
|
let happy_drop = jspg_validate(
|
||||||
|
"source_schema",
|
||||||
|
JsonB(json!({"type": "source_schema", "name": "Neo"})),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
happy_drop.0,
|
happy_drop.0,
|
||||||
json!({
|
json!({
|
||||||
@ -235,7 +239,10 @@ fn test_library_api() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 5. Validate Unhappy Path
|
// 5. Validate Unhappy Path
|
||||||
let unhappy_drop = jspg_validate("source_schema", JsonB(json!({"type": "source_schema", "wrong": "data"})));
|
let unhappy_drop = jspg_validate(
|
||||||
|
"source_schema",
|
||||||
|
JsonB(json!({"type": "source_schema", "wrong": "data"})),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unhappy_drop.0,
|
unhappy_drop.0,
|
||||||
json!({
|
json!({
|
||||||
|
|||||||
@ -42,7 +42,7 @@ fn get_cached_file(path: &str) -> CompiledSuite {
|
|||||||
|
|
||||||
let mut compiled_suites = Vec::new();
|
let mut compiled_suites = Vec::new();
|
||||||
for suite in suites {
|
for suite in suites {
|
||||||
let (db, drop) = crate::database::Database::new(&suite.database);
|
let (db, drop) = crate::database::Database::new(suite.database.clone());
|
||||||
let compiled_db = if drop.errors.is_empty() {
|
let compiled_db = if drop.errors.is_empty() {
|
||||||
Ok(Arc::new(db))
|
Ok(Arc::new(db))
|
||||||
} else {
|
} else {
|
||||||
@ -127,7 +127,7 @@ pub fn run_test_case(path: &str, suite_idx: usize, case_idx: usize) -> Result<()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"merge" => {
|
"merge" => {
|
||||||
let result = test.run_merge(db_unwrapped.unwrap());
|
let result = test.run_merge(db_unwrapped.unwrap(), path, suite_idx, case_idx);
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
println!("TEST MERGE ERROR FOR '{}': {}", test.description, e);
|
println!("TEST MERGE ERROR FOR '{}': {}", test.description, e);
|
||||||
failures.push(format!(
|
failures.push(format!(
|
||||||
@ -137,7 +137,7 @@ pub fn run_test_case(path: &str, suite_idx: usize, case_idx: usize) -> Result<()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"query" => {
|
"query" => {
|
||||||
let result = test.run_query(db_unwrapped.unwrap());
|
let result = test.run_query(db_unwrapped.unwrap(), path, suite_idx, case_idx);
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
println!("TEST QUERY ERROR FOR '{}': {}", test.description, e);
|
println!("TEST QUERY ERROR FOR '{}': {}", test.description, e);
|
||||||
failures.push(format!(
|
failures.push(format!(
|
||||||
@ -160,3 +160,83 @@ pub fn run_test_case(path: &str, suite_idx: usize, case_idx: usize) -> Result<()
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn extract_uuids(val: &Value, path: &str, map: &mut HashMap<String, String>) {
|
||||||
|
let uuid_re = regex::Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$").unwrap();
|
||||||
|
|
||||||
|
match val {
|
||||||
|
Value::Object(obj) => {
|
||||||
|
for (k, v) in obj {
|
||||||
|
let new_path = if path.is_empty() { k.clone() } else { format!("{}.{}", path, k) };
|
||||||
|
extract_uuids(v, &new_path, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(arr) => {
|
||||||
|
for (i, v) in arr.iter().enumerate() {
|
||||||
|
let new_path = if path.is_empty() { i.to_string() } else { format!("{}.{}", path, i) };
|
||||||
|
extract_uuids(v, &new_path, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::String(s) => {
|
||||||
|
if s != "00000000-0000-0000-0000-000000000000" && uuid_re.is_match(s) {
|
||||||
|
map.insert(s.clone(), path.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn canonicalize_with_map(s: &str, uuid_map: &HashMap<String, String>, gen_map: &mut HashMap<String, usize>) -> String {
|
||||||
|
let uuid_re = regex::Regex::new(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}").unwrap();
|
||||||
|
let s1 = uuid_re.replace_all(s, |caps: ®ex::Captures| {
|
||||||
|
let val = &caps[0];
|
||||||
|
if val == "00000000-0000-0000-0000-000000000000" {
|
||||||
|
val.to_string()
|
||||||
|
} else if let Some(path) = uuid_map.get(val) {
|
||||||
|
format!("{{{{uuid:{}}}}}", path)
|
||||||
|
} else {
|
||||||
|
let next_idx = gen_map.len();
|
||||||
|
let idx = *gen_map.entry(val.to_string()).or_insert(next_idx);
|
||||||
|
format!("{{{{uuid:generated_{}}}}}", idx)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let ts_re = regex::Regex::new(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|\+\d{2}(?::\d{2})?)?").unwrap();
|
||||||
|
ts_re.replace_all(&s1, "{{timestamp}}").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_sql_fixture(path: &str, suite_idx: usize, case_idx: usize, queries: &[String]) {
|
||||||
|
use crate::tests::formatter::SqlFormatter;
|
||||||
|
let content = fs::read_to_string(path).unwrap();
|
||||||
|
let mut file_data: Value = serde_json::from_str(&content).unwrap();
|
||||||
|
|
||||||
|
let mut uuid_map = HashMap::new();
|
||||||
|
if let Some(test_case) = file_data.get(suite_idx).and_then(|s| s.get("tests")).and_then(|t| t.get(case_idx)) {
|
||||||
|
if let Some(data) = test_case.get("data") {
|
||||||
|
extract_uuids(data, "data", &mut uuid_map);
|
||||||
|
}
|
||||||
|
if let Some(mocks) = test_case.get("mocks") {
|
||||||
|
extract_uuids(mocks, "mocks", &mut uuid_map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut gen_map = HashMap::new();
|
||||||
|
|
||||||
|
let mut formatted_sql = Vec::new();
|
||||||
|
for q in queries {
|
||||||
|
let res = SqlFormatter::format(q);
|
||||||
|
let mapped_res: Vec<String> = res.into_iter().map(|l| canonicalize_with_map(&l, &uuid_map, &mut gen_map)).collect();
|
||||||
|
formatted_sql.push(mapped_res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expect) = file_data[suite_idx]["tests"][case_idx].get_mut("expect") {
|
||||||
|
if let Some(obj) = expect.as_object_mut() {
|
||||||
|
obj.remove("pattern");
|
||||||
|
obj.insert("sql".to_string(), serde_json::json!(formatted_sql));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// To preserve original formatting, we just use serde_json pretty output
|
||||||
|
let formatted_json = serde_json::to_string_pretty(&file_data).unwrap();
|
||||||
|
fs::write(path, formatted_json).unwrap();
|
||||||
|
}
|
||||||
|
|||||||
@ -75,7 +75,7 @@ impl Case {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_merge(&self, db: Arc<Database>) -> Result<(), String> {
|
pub fn run_merge(&self, db: Arc<Database>, path: &str, suite_idx: usize, case_idx: usize) -> Result<(), String> {
|
||||||
if let Some(mocks) = &self.mocks {
|
if let Some(mocks) = &self.mocks {
|
||||||
if let Some(arr) = mocks.as_array() {
|
if let Some(arr) = mocks.as_array() {
|
||||||
db.executor.set_mocks(arr.clone());
|
db.executor.set_mocks(arr.clone());
|
||||||
@ -94,7 +94,10 @@ impl Case {
|
|||||||
} else if result.errors.is_empty() {
|
} else if result.errors.is_empty() {
|
||||||
// Only assert SQL if merge succeeded
|
// Only assert SQL if merge succeeded
|
||||||
let queries = db.executor.get_queries();
|
let queries = db.executor.get_queries();
|
||||||
expect.assert_pattern(&queries).and_then(|_| expect.assert_sql(&queries))
|
if std::env::var("UPDATE_EXPECT").is_ok() {
|
||||||
|
crate::tests::runner::update_sql_fixture(path, suite_idx, case_idx, &queries);
|
||||||
|
}
|
||||||
|
expect.assert_sql(&queries)
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -106,7 +109,7 @@ impl Case {
|
|||||||
return_val
|
return_val
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_query(&self, db: Arc<Database>) -> Result<(), String> {
|
pub fn run_query(&self, db: Arc<Database>, path: &str, suite_idx: usize, case_idx: usize) -> Result<(), String> {
|
||||||
if let Some(mocks) = &self.mocks {
|
if let Some(mocks) = &self.mocks {
|
||||||
if let Some(arr) = mocks.as_array() {
|
if let Some(arr) = mocks.as_array() {
|
||||||
db.executor.set_mocks(arr.clone());
|
db.executor.set_mocks(arr.clone());
|
||||||
@ -123,7 +126,10 @@ impl Case {
|
|||||||
Err(format!("Query {}", e))
|
Err(format!("Query {}", e))
|
||||||
} else if result.errors.is_empty() {
|
} else if result.errors.is_empty() {
|
||||||
let queries = db.executor.get_queries();
|
let queries = db.executor.get_queries();
|
||||||
expect.assert_pattern(&queries).and_then(|_| expect.assert_sql(&queries))
|
if std::env::var("UPDATE_EXPECT").is_ok() {
|
||||||
|
crate::tests::runner::update_sql_fixture(path, suite_idx, case_idx, &queries);
|
||||||
|
}
|
||||||
|
expect.assert_sql(&queries)
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ pub struct ValidationContext<'a> {
|
|||||||
pub extensible: bool,
|
pub extensible: bool,
|
||||||
pub reporter: bool,
|
pub reporter: bool,
|
||||||
pub overrides: HashSet<String>,
|
pub overrides: HashSet<String>,
|
||||||
pub parent: Option<&'a serde_json::Value>,
|
pub parents: Vec<&'a serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ValidationContext<'a> {
|
impl<'a> ValidationContext<'a> {
|
||||||
@ -39,7 +39,7 @@ impl<'a> ValidationContext<'a> {
|
|||||||
extensible: effective_extensible,
|
extensible: effective_extensible,
|
||||||
reporter,
|
reporter,
|
||||||
overrides,
|
overrides,
|
||||||
parent: None,
|
parents: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +63,11 @@ impl<'a> ValidationContext<'a> {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
let effective_extensible = schema.extensible.unwrap_or(extensible);
|
let effective_extensible = schema.extensible.unwrap_or(extensible);
|
||||||
|
|
||||||
|
let mut parents = self.parents.clone();
|
||||||
|
if let Some(p) = parent_instance {
|
||||||
|
parents.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
db: self.db,
|
db: self.db,
|
||||||
root: self.root,
|
root: self.root,
|
||||||
@ -73,7 +78,7 @@ impl<'a> ValidationContext<'a> {
|
|||||||
extensible: effective_extensible,
|
extensible: effective_extensible,
|
||||||
reporter,
|
reporter,
|
||||||
overrides,
|
overrides,
|
||||||
parent: parent_instance,
|
parents,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,7 +90,7 @@ impl<'a> ValidationContext<'a> {
|
|||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
self.extensible,
|
self.extensible,
|
||||||
reporter,
|
reporter,
|
||||||
self.parent,
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use crate::validator::context::ValidationContext;
|
use crate::validator::context::ValidationContext;
|
||||||
use crate::validator::error::ValidationError;
|
use crate::validator::error::ValidationError;
|
||||||
use crate::validator::result::ValidationResult;
|
use crate::validator::result::ValidationResult;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
|
||||||
impl<'a> ValidationContext<'a> {
|
impl<'a> ValidationContext<'a> {
|
||||||
pub(crate) fn validate_family(
|
pub(crate) fn validate_family(
|
||||||
@ -65,7 +66,7 @@ impl<'a> ValidationContext<'a> {
|
|||||||
|
|
||||||
pub(crate) fn execute_polymorph(
|
pub(crate) fn execute_polymorph(
|
||||||
&self,
|
&self,
|
||||||
options: &std::collections::BTreeMap<String, (Option<usize>, Option<String>)>,
|
options: &IndexMap<String, (Option<usize>, Option<String>)>,
|
||||||
result: &mut ValidationResult,
|
result: &mut ValidationResult,
|
||||||
) -> Result<bool, ValidationError> {
|
) -> Result<bool, ValidationError> {
|
||||||
// 1. O(1) Fast-Path Router & Extractor
|
// 1. O(1) Fast-Path Router & Extractor
|
||||||
|
|||||||
@ -59,12 +59,13 @@ impl<'a> ValidationContext<'a> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut resolved = false;
|
let mut resolved = false;
|
||||||
if let Some(parent) = self.parent {
|
for parent in self.parents.iter().rev() {
|
||||||
if let Some(obj) = parent.as_object() {
|
if let Some(obj) = parent.as_object() {
|
||||||
if let Some(val) = obj.get(var_name) {
|
if let Some(val) = obj.get(var_name) {
|
||||||
if let Some(str_val) = val.as_str() {
|
if let Some(str_val) = val.as_str() {
|
||||||
target_id = format!("{}{}", str_val, suffix);
|
target_id = format!("{}{}", str_val, suffix);
|
||||||
resolved = true;
|
resolved = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -97,7 +98,7 @@ impl<'a> ValidationContext<'a> {
|
|||||||
new_overrides,
|
new_overrides,
|
||||||
self.extensible,
|
self.extensible,
|
||||||
true, // Reporter mode
|
true, // Reporter mode
|
||||||
self.parent,
|
None,
|
||||||
);
|
);
|
||||||
shadow.root = &global_schema;
|
shadow.root = &global_schema;
|
||||||
result.merge(shadow.validate()?);
|
result.merge(shadow.validate()?);
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
import json
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Read the test output
|
|
||||||
output = """
|
|
||||||
JSPG_SQL: INSERT INTO agreego."entity" ("created_at", "created_by", "id", "modified_at", "modified_by", "type") VALUES ('2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000', '734f0f6e-3408-4d18-a6d7-725400ff6b30', '2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000', 'person')
|
|
||||||
JSPG_SQL: INSERT INTO agreego."organization" ("id", "type") VALUES ('734f0f6e-3408-4d18-a6d7-725400ff6b30', 'person')
|
|
||||||
JSPG_SQL: INSERT INTO agreego."user" ("id", "type") VALUES ('734f0f6e-3408-4d18-a6d7-725400ff6b30', 'person')
|
|
||||||
JSPG_SQL: INSERT INTO agreego."person" ("first_name", "id", "last_name", "type") VALUES ('Const', '734f0f6e-3408-4d18-a6d7-725400ff6b30', 'Person', 'person')
|
|
||||||
JSPG_SQL: INSERT INTO agreego.change ("old", "new", entity_id, id, kind, modified_at, modified_by) VALUES (NULL, '{"first_name":"Const","last_name":"Person","type":"person"}', '734f0f6e-3408-4d18-a6d7-725400ff6b30', '7195460a-edff-4d0d-b137-c040616b9f27', 'create', '2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000')
|
|
||||||
JSPG_SQL: INSERT INTO agreego."entity" ("created_at", "created_by", "id", "modified_at", "modified_by", "organization_id", "type") VALUES ('2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000', '369e92ac-41c5-4d43-9286-c004edb96e76', '2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000', 'parent-org-id', 'order')
|
|
||||||
JSPG_SQL: INSERT INTO agreego."order" ("customer_id", "id", "type") VALUES ('734f0f6e-3408-4d18-a6d7-725400ff6b30', '369e92ac-41c5-4d43-9286-c004edb96e76', 'order')
|
|
||||||
JSPG_SQL: INSERT INTO agreego."entity" ("created_at", "created_by", "id", "modified_at", "modified_by", "organization_id", "type") VALUES ('2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000', '48e91d8d-99ef-4f74-b2e6-c98f9501bb7a', '2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000', 'parent-org-id', 'order_line')
|
|
||||||
JSPG_SQL: INSERT INTO agreego."order_line" ("id", "order_id", "type") VALUES ('48e91d8d-99ef-4f74-b2e6-c98f9501bb7a', '369e92ac-41c5-4d43-9286-c004edb96e76', 'order_line')
|
|
||||||
JSPG_SQL: INSERT INTO agreego.change ("old", "new", entity_id, id, kind, modified_at, modified_by) VALUES (NULL, '{"order_id":"369e92ac-41c5-4d43-9286-c004edb96e76","organization_id":"parent-org-id","type":"order_line"}', '48e91d8d-99ef-4f74-b2e6-c98f9501bb7a', '5ab5c99b-926a-4878-98a7-c531859d2ebe', 'create', '2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000')
|
|
||||||
JSPG_SQL: INSERT INTO agreego."entity" ("created_at", "created_by", "id", "modified_at", "modified_by", "organization_id", "type") VALUES ('2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000', 'b91b93b2-1f75-4be3-a731-88562d289997', '2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000', 'explicit-org-id', 'order_line')
|
|
||||||
JSPG_SQL: INSERT INTO agreego."order_line" ("id", "order_id", "type") VALUES ('b91b93b2-1f75-4be3-a731-88562d289997', '369e92ac-41c5-4d43-9286-c004edb96e76', 'order_line')
|
|
||||||
JSPG_SQL: INSERT INTO agreego.change ("old", "new", entity_id, id, kind, modified_at, modified_by) VALUES (NULL, '{"order_id":"369e92ac-41c5-4d43-9286-c004edb96e76","organization_id":"explicit-org-id","type":"order_line"}', 'b91b93b2-1f75-4be3-a731-88562d289997', 'ad35cf4e-d2de-4f87-aa3d-ec30101397ca', 'create', '2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000')
|
|
||||||
JSPG_SQL: INSERT INTO agreego.change ("old", "new", entity_id, id, kind, modified_at, modified_by) VALUES (NULL, '{"customer_id":"734f0f6e-3408-4d18-a6d7-725400ff6b30","organization_id":"parent-org-id","type":"order"}', '369e92ac-41c5-4d43-9286-c004edb96e76', '4646bcc7-e1dd-45f7-ba66-33175844fa79', 'create', '2026-03-10T00:00:00Z', '00000000-0000-0000-0000-000000000000')
|
|
||||||
JSPG_SQL: SELECT pg_notify('entity', '{"complete":{"created_at":"2026-03-10T00:00:00Z","created_by":"00000000-0000-0000-0000-000000000000","customer_id":"734f0f6e-3408-4d18-a6d7-725400ff6b30","id":"369e92ac-41c5-4d43-9286-c004edb96e76","modified_at":"2026-03-10T00:00:00Z","modified_by":"00000000-0000-0000-0000-000000000000","organization_id":"parent-org-id","type":"order"},"new":{"customer_id":"734f0f6e-3408-4d18-a6d7-725400ff6b30","organization_id":"parent-org-id","type":"order"}}')
|
|
||||||
JSPG_SQL: SELECT pg_notify('entity', '{"complete":{"created_at":"2026-03-10T00:00:00Z","created_by":"00000000-0000-0000-0000-000000000000","first_name":"Const","id":"734f0f6e-3408-4d18-a6d7-725400ff6b30","last_name":"Person","modified_at":"2026-03-10T00:00:00Z","modified_by":"00000000-0000-0000-0000-000000000000","type":"person"},"new":{"first_name":"Const","last_name":"Person","type":"person"}}')
|
|
||||||
JSPG_SQL: SELECT pg_notify('entity', '{"complete":{"created_at":"2026-03-10T00:00:00Z","created_by":"00000000-0000-0000-0000-000000000000","id":"48e91d8d-99ef-4f74-b2e6-c98f9501bb7a","modified_at":"2026-03-10T00:00:00Z","modified_by":"00000000-0000-0000-0000-000000000000","order_id":"369e92ac-41c5-4d43-9286-c004edb96e76","organization_id":"parent-org-id","type":"order_line"},"new":{"order_id":"369e92ac-41c5-4d43-9286-c004edb96e76","organization_id":"parent-org-id","type":"order_line"}}')
|
|
||||||
JSPG_SQL: SELECT pg_notify('entity', '{"complete":{"created_at":"2026-03-10T00:00:00Z","created_by":"00000000-0000-0000-0000-000000000000","id":"b91b93b2-1f75-4be3-a731-88562d289997","modified_at":"2026-03-10T00:00:00Z","modified_by":"00000000-0000-0000-0000-000000000000","order_id":"369e92ac-41c5-4d43-9286-c004edb96e76","organization_id":"explicit-org-id","type":"order_line"},"new":{"order_id":"369e92ac-41c5-4d43-9286-c004edb96e76","organization_id":"explicit-org-id","type":"order_line"}}')
|
|
||||||
"""
|
|
||||||
|
|
||||||
lines = [line.replace("JSPG_SQL: ", "").strip() for line in output.split("\n") if line.startswith("JSPG_SQL: ")]
|
|
||||||
|
|
||||||
person_id = "734f0f6e-3408-4d18-a6d7-725400ff6b30"
|
|
||||||
order_id = "369e92ac-41c5-4d43-9286-c004edb96e76"
|
|
||||||
line1_id = "48e91d8d-99ef-4f74-b2e6-c98f9501bb7a"
|
|
||||||
line2_id = "b91b93b2-1f75-4be3-a731-88562d289997"
|
|
||||||
|
|
||||||
def replace_ids(s):
|
|
||||||
s = s.replace(person_id, "{{uuid:person_id}}")
|
|
||||||
s = s.replace(order_id, "{{uuid:order_id}}")
|
|
||||||
s = s.replace(line1_id, "{{uuid:line1_id}}")
|
|
||||||
s = s.replace(line2_id, "{{uuid:line2_id}}")
|
|
||||||
s = re.sub(r"'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}'", "'{{uuid}}'", s)
|
|
||||||
s = s.replace("'2026-03-10T00:00:00Z'", "'{{timestamp}}'")
|
|
||||||
s = s.replace('"2026-03-10T00:00:00Z"', '"{{timestamp}}"')
|
|
||||||
return s
|
|
||||||
|
|
||||||
new_sql = []
|
|
||||||
for line in lines:
|
|
||||||
replaced = replace_ids(line)
|
|
||||||
new_sql.append([replaced]) # Simple array of single string elements for now, test runner doesn't mind formatting
|
|
||||||
|
|
||||||
# format properly like existing tests (split by VALUES)
|
|
||||||
formatted_sql = []
|
|
||||||
for sql_arr in new_sql:
|
|
||||||
sql = sql_arr[0]
|
|
||||||
if "VALUES" in sql and "INSERT INTO" in sql:
|
|
||||||
parts = sql.split(" VALUES ")
|
|
||||||
|
|
||||||
insert_part = parts[0]
|
|
||||||
values_part = parts[1]
|
|
||||||
|
|
||||||
insert_tokens = insert_part.split(" (")
|
|
||||||
table = insert_tokens[0]
|
|
||||||
cols = insert_tokens[1][:-1].split(", ")
|
|
||||||
|
|
||||||
# reconstruct with indent
|
|
||||||
new_cmd = [
|
|
||||||
table + " (",
|
|
||||||
]
|
|
||||||
for i, col in enumerate(cols):
|
|
||||||
new_cmd.append(" " + col + ("," if i < len(cols) - 1 else ""))
|
|
||||||
new_cmd.append(")")
|
|
||||||
new_cmd.append("VALUES (")
|
|
||||||
|
|
||||||
vals = values_part[1:-1].split(", ")
|
|
||||||
# if val is json, it might have commas
|
|
||||||
# simple split won't work well for json.
|
|
||||||
# we can just use the raw sql without pretty print, test runner handles arrays of strings just by joining them with spaces
|
|
||||||
|
|
||||||
# Just format using the test runner's expected format. Test runner joins with space or newline
|
|
||||||
# To be safe, just split into arbitrary chunks
|
|
||||||
formatted_sql.append([sql])
|
|
||||||
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
test_case = next(t for t in data[0]["tests"] if t["description"] == "Test organization_id syntactic sugar permutations")
|
|
||||||
test_case["expect"]["sql"] = formatted_sql
|
|
||||||
|
|
||||||
with open("fixtures/merger.json", "w") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
Reference in New Issue
Block a user