diff --git a/fixtures/merger.json b/fixtures/merger.json index 3688345..f2fd396 100644 --- a/fixtures/merger.json +++ b/fixtures/merger.json @@ -1320,62 +1320,17 @@ " AND \"pronouns\" = 'they/them'))" ], [ - "INSERT INTO agreego.\"entity\" (", - " \"created_at\",", - " \"created_by\",", - " \"id\",", - " \"modified_at\",", - " \"modified_by\",", - " \"type\"", - ")", - "VALUES (", - " '{{timestamp}}',", - " '00000000-0000-0000-0000-000000000000',", - " '{{uuid:generated_0}}',", - " '{{timestamp}}',", - " '00000000-0000-0000-0000-000000000000',", - " 'person'", - ")" + "UPDATE agreego.\"entity\" SET", + " modified_at = '{{timestamp}}',", + " modified_by = '00000000-0000-0000-0000-000000000000'", + "WHERE", + " id = '{{uuid:mocks.0.id}}'" ], [ - "INSERT INTO agreego.\"organization\" (", - " \"id\",", - " \"type\"", - ")", - "VALUES (", - " '{{uuid:generated_0}}',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"user\" (", - " \"id\",", - " \"type\"", - ")", - "VALUES (", - " '{{uuid:generated_0}}',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"person\" (", - " \"contact_id\",", - " \"date_of_birth\",", - " \"first_name\",", - " \"id\",", - " \"last_name\",", - " \"pronouns\",", - " \"type\"", - ")", - "VALUES (", - " 'abc-contact',", - " '{{timestamp}}',", - " 'LookupFirst',", - " '{{uuid:generated_0}}',", - " 'LookupLast',", - " 'they/them',", - " 'person'", - ")" + "UPDATE agreego.\"person\" SET", + " contact_id = 'abc-contact'", + "WHERE", + " id = '{{uuid:mocks.0.id}}'" ], [ "INSERT INTO agreego.change (", @@ -1388,45 +1343,40 @@ " \"modified_by\"", ")", "VALUES (", - " NULL,", " '{", - " \"first_name\": \"LookupFirst\",", - " \"last_name\": \"LookupLast\",", - " \"date_of_birth\": \"{{timestamp}}\",", - " \"pronouns\": \"they/them\",", + " \"contact_id\": \"old-contact\"", + " }',", + " '{", " \"contact_id\": \"abc-contact\",", " \"type\": \"person\"", " }',", + " '{{uuid:mocks.0.id}}',", " '{{uuid:generated_0}}',", - " '{{uuid:generated_1}}',", - " 'create',", + " 'update',", " '{{timestamp}}',", " '00000000-0000-0000-0000-000000000000'", ")" ], [ "(SELECT pg_notify('entity', '{", - " \"kind\": \"create\",", + " \"kind\": \"update\",", " \"complete\": {", + " \"id\": \"{{uuid:mocks.0.id}}\",", + " \"type\": \"person\",", " \"first_name\": \"LookupFirst\",", " \"last_name\": \"LookupLast\",", " \"date_of_birth\": \"{{timestamp}}\",", " \"pronouns\": \"they/them\",", " \"contact_id\": \"abc-contact\",", - " \"id\": \"{{uuid:generated_0}}\",", - " \"type\": \"person\",", - " \"created_by\": \"00000000-0000-0000-0000-000000000000\",", - " \"created_at\": \"{{timestamp}}\",", " \"modified_by\": \"00000000-0000-0000-0000-000000000000\",", " \"modified_at\": \"{{timestamp}}\"", " },", " \"new\": {", - " \"first_name\": \"LookupFirst\",", - " \"last_name\": \"LookupLast\",", - " \"date_of_birth\": \"{{timestamp}}\",", - " \"pronouns\": \"they/them\",", " \"contact_id\": \"abc-contact\",", " \"type\": \"person\"", + " },", + " \"old\": {", + " \"contact_id\": \"old-contact\"", " }", "}'))" ] @@ -1464,62 +1414,17 @@ "(SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*) FROM agreego.\"entity\" t1 LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id WHERE t1.id = '{{uuid:data.id}}' UNION SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*) FROM agreego.\"entity\" t1 LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id WHERE (\"first_name\" = 'LookupFirst' AND \"last_name\" = 'LookupLast' AND \"date_of_birth\" = '{{timestamp}}' AND \"pronouns\" = 'they/them'))" ], [ - "INSERT INTO agreego.\"entity\" (", - " \"created_at\",", - " \"created_by\",", - " \"id\",", - " \"modified_at\",", - " \"modified_by\",", - " \"type\"", - ")", - "VALUES (", - " '{{timestamp}}',", - " '00000000-0000-0000-0000-000000000000',", - " '{{uuid:data.id}}',", - " '{{timestamp}}',", - " '00000000-0000-0000-0000-000000000000',", - " 'person'", - ")" + "UPDATE agreego.\"entity\" SET", + " modified_at = '{{timestamp}}',", + " modified_by = '00000000-0000-0000-0000-000000000000'", + "WHERE", + " id = '{{uuid:mocks.0.id}}'" ], [ - "INSERT INTO agreego.\"organization\" (", - " \"id\",", - " \"type\"", - ")", - "VALUES (", - " '{{uuid:data.id}}',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"user\" (", - " \"id\",", - " \"type\"", - ")", - "VALUES (", - " '{{uuid:data.id}}',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"person\" (", - " \"contact_id\",", - " \"date_of_birth\",", - " \"first_name\",", - " \"id\",", - " \"last_name\",", - " \"pronouns\",", - " \"type\"", - ")", - "VALUES (", - " 'abc-contact',", - " '{{timestamp}}',", - " 'LookupFirst',", - " '{{uuid:data.id}}',", - " 'LookupLast',", - " 'they/them',", - " 'person'", - ")" + "UPDATE agreego.\"person\" SET", + " contact_id = 'abc-contact'", + "WHERE", + " id = '{{uuid:mocks.0.id}}'" ], [ "INSERT INTO agreego.change (", @@ -1532,46 +1437,42 @@ " \"modified_by\"", ")", "VALUES (", - " NULL,", " '{", - " \"first_name\": \"LookupFirst\",", - " \"last_name\": \"LookupLast\",", - " \"date_of_birth\": \"{{timestamp}}\",", - " \"pronouns\": \"they/them\",", + " \"contact_id\": \"old-contact\"", + " }',", + " '{", " \"contact_id\": \"abc-contact\",", " \"type\": \"person\"", " }',", - " '{{uuid:data.id}}',", + " '{{uuid:mocks.0.id}}',", " '{{uuid:generated_0}}',", - " 'create',", + " 'update',", " '{{timestamp}}',", " '00000000-0000-0000-0000-000000000000'", ")" ], [ "(SELECT pg_notify('entity', '{", - " \"kind\": \"create\",", + " \"kind\": \"update\",", " \"complete\": {", + " \"id\": \"{{uuid:mocks.0.id}}\",", + " \"type\": \"person\",", " \"first_name\": \"LookupFirst\",", " \"last_name\": \"LookupLast\",", " \"date_of_birth\": \"{{timestamp}}\",", " \"pronouns\": \"they/them\",", " \"contact_id\": \"abc-contact\",", - " \"id\": \"{{uuid:data.id}}\",", - " \"type\": \"person\",", - " \"created_by\": \"00000000-0000-0000-0000-000000000000\",", - " \"created_at\": \"{{timestamp}}\",", " \"modified_by\": \"00000000-0000-0000-0000-000000000000\",", " \"modified_at\": \"{{timestamp}}\"", " },", " \"new\": {", - " \"first_name\": \"LookupFirst\",", - " \"last_name\": \"LookupLast\",", - " \"date_of_birth\": \"{{timestamp}}\",", - " \"pronouns\": \"they/them\",", " \"contact_id\": \"abc-contact\",", " \"type\": \"person\"", - " }", + " },", + " \"old\": {", + " \"contact_id\": \"old-contact\"", + " },", + " \"replaces\": \"{{uuid:data.id}}\"", "}'))" ] ] @@ -1606,110 +1507,24 @@ [ "(SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*) FROM agreego.\"entity\" t1 LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id WHERE t1.id = '{{uuid:data.id}}' UNION SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*) FROM agreego.\"entity\" t1 LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id WHERE (\"first_name\" = 'LookupFirst' AND \"last_name\" = 'LookupLast' AND \"date_of_birth\" = '{{timestamp}}' AND \"pronouns\" = 'they/them'))" ], - [ - "INSERT INTO agreego.\"entity\" (", - " \"created_at\",", - " \"created_by\",", - " \"id\",", - " \"modified_at\",", - " \"modified_by\",", - " \"type\"", - ")", - "VALUES (", - " '{{timestamp}}',", - " '00000000-0000-0000-0000-000000000000',", - " '{{uuid:data.id}}',", - " '{{timestamp}}',", - " '00000000-0000-0000-0000-000000000000',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"organization\" (", - " \"id\",", - " \"type\"", - ")", - "VALUES (", - " '{{uuid:data.id}}',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"user\" (", - " \"id\",", - " \"type\"", - ")", - "VALUES (", - " '{{uuid:data.id}}',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"person\" (", - " \"date_of_birth\",", - " \"first_name\",", - " \"id\",", - " \"last_name\",", - " \"pronouns\",", - " \"type\"", - ")", - "VALUES (", - " '{{timestamp}}',", - " 'LookupFirst',", - " '{{uuid:data.id}}',", - " 'LookupLast',", - " 'they/them',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.change (", - " \"old\",", - " \"new\",", - " \"entity_id\",", - " \"id\",", - " \"kind\",", - " \"modified_at\",", - " \"modified_by\"", - ")", - "VALUES (", - " NULL,", - " '{", - " \"first_name\": \"LookupFirst\",", - " \"last_name\": \"LookupLast\",", - " \"date_of_birth\": \"{{timestamp}}\",", - " \"pronouns\": \"they/them\",", - " \"type\": \"person\"", - " }',", - " '{{uuid:data.id}}',", - " '{{uuid:generated_0}}',", - " 'create',", - " '{{timestamp}}',", - " '00000000-0000-0000-0000-000000000000'", - ")" - ], [ "(SELECT pg_notify('entity', '{", - " \"kind\": \"create\",", + " \"kind\": \"replace\",", " \"complete\": {", + " \"id\": \"{{uuid:mocks.0.id}}\",", + " \"type\": \"person\",", " \"first_name\": \"LookupFirst\",", " \"last_name\": \"LookupLast\",", " \"date_of_birth\": \"{{timestamp}}\",", " \"pronouns\": \"they/them\",", - " \"id\": \"{{uuid:data.id}}\",", - " \"type\": \"person\",", - " \"created_by\": \"00000000-0000-0000-0000-000000000000\",", - " \"created_at\": \"{{timestamp}}\",", + " \"contact_id\": \"old-contact\",", " \"modified_by\": \"00000000-0000-0000-0000-000000000000\",", " \"modified_at\": \"{{timestamp}}\"", " },", " \"new\": {", - " \"first_name\": \"LookupFirst\",", - " \"last_name\": \"LookupLast\",", - " \"date_of_birth\": \"{{timestamp}}\",", - " \"pronouns\": \"they/them\",", " \"type\": \"person\"", - " }", + " },", + " \"replaces\": \"{{uuid:data.id}}\"", "}'))" ] ] @@ -1746,56 +1561,18 @@ " t1.id = '{{uuid:mocks.0.id}}')" ], [ - "INSERT INTO agreego.\"entity\" (", - " \"created_at\",", - " \"created_by\",", - " \"id\",", - " \"modified_at\",", - " \"modified_by\",", - " \"type\"", - ")", - "VALUES (", - " '{{timestamp}}',", - " '00000000-0000-0000-0000-000000000000',", - " '{{uuid:mocks.0.id}}',", - " '{{timestamp}}',", - " '00000000-0000-0000-0000-000000000000',", - " 'person'", - ")" + "UPDATE agreego.\"entity\" SET", + " modified_at = '{{timestamp}}',", + " modified_by = '00000000-0000-0000-0000-000000000000'", + "WHERE", + " id = '{{uuid:mocks.0.id}}'" ], [ - "INSERT INTO agreego.\"organization\" (", - " \"id\",", - " \"type\"", - ")", - "VALUES (", - " '{{uuid:mocks.0.id}}',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"user\" (", - " \"id\",", - " \"type\"", - ")", - "VALUES (", - " '{{uuid:mocks.0.id}}',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"person\" (", - " \"first_name\",", - " \"id\",", - " \"last_name\",", - " \"type\"", - ")", - "VALUES (", - " 'NewFirst',", - " '{{uuid:mocks.0.id}}',", - " 'NewLast',", - " 'person'", - ")" + "UPDATE agreego.\"person\" SET", + " first_name = 'NewFirst',", + " last_name = 'NewLast'", + "WHERE", + " id = '{{uuid:mocks.0.id}}'" ], [ "INSERT INTO agreego.change (", @@ -1808,7 +1585,10 @@ " \"modified_by\"", ")", "VALUES (", - " NULL,", + " '{", + " \"first_name\": \"OldFirst\",", + " \"last_name\": \"OldLast\"", + " }',", " '{", " \"first_name\": \"NewFirst\",", " \"last_name\": \"NewLast\",", @@ -1816,21 +1596,19 @@ " }',", " '{{uuid:mocks.0.id}}',", " '{{uuid:generated_0}}',", - " 'create',", + " 'update',", " '{{timestamp}}',", " '00000000-0000-0000-0000-000000000000'", ")" ], [ "(SELECT pg_notify('entity', '{", - " \"kind\": \"create\",", + " \"kind\": \"update\",", " \"complete\": {", - " \"first_name\": \"NewFirst\",", - " \"last_name\": \"NewLast\",", " \"id\": \"{{uuid:mocks.0.id}}\",", " \"type\": \"person\",", - " \"created_by\": \"00000000-0000-0000-0000-000000000000\",", - " \"created_at\": \"{{timestamp}}\",", + " \"first_name\": \"NewFirst\",", + " \"last_name\": \"NewLast\",", " \"modified_by\": \"00000000-0000-0000-0000-000000000000\",", " \"modified_at\": \"{{timestamp}}\"", " },", @@ -1838,6 +1616,10 @@ " \"first_name\": \"NewFirst\",", " \"last_name\": \"NewLast\",", " \"type\": \"person\"", + " },", + " \"old\": {", + " \"first_name\": \"OldFirst\",", + " \"last_name\": \"OldLast\"", " }", "}'))" ] @@ -3006,54 +2788,12 @@ " t1.id = 'abc-archived')" ], [ - "INSERT INTO agreego.\"entity\" (", - " \"archived\",", - " \"created_at\",", - " \"created_by\",", - " \"id\",", - " \"modified_at\",", - " \"modified_by\",", - " \"type\"", - ")", - "VALUES (", - " true,", - " '{{timestamp}}',", - " '00000000-0000-0000-0000-000000000000',", - " 'abc-archived',", - " '{{timestamp}}',", - " '00000000-0000-0000-0000-000000000000',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"organization\" (", - " \"id\",", - " \"type\"", - ")", - "VALUES (", - " 'abc-archived',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"user\" (", - " \"id\",", - " \"type\"", - ")", - "VALUES (", - " 'abc-archived',", - " 'person'", - ")" - ], - [ - "INSERT INTO agreego.\"person\" (", - " \"id\",", - " \"type\"", - ")", - "VALUES (", - " 'abc-archived',", - " 'person'", - ")" + "UPDATE agreego.\"entity\" SET", + " archived = true,", + " modified_at = '{{timestamp}}',", + " modified_by = '00000000-0000-0000-0000-000000000000'", + "WHERE", + " id = 'abc-archived'" ], [ "INSERT INTO agreego.change (", @@ -3066,33 +2806,38 @@ " \"modified_by\"", ")", "VALUES (", - " NULL,", + " '{", + " \"archived\": false", + " }',", " '{", " \"archived\": true,", " \"type\": \"person\"", " }',", " 'abc-archived',", " '{{uuid:generated_0}}',", - " 'create',", + " 'delete',", " '{{timestamp}}',", " '00000000-0000-0000-0000-000000000000'", ")" ], [ "(SELECT pg_notify('entity', '{", - " \"kind\": \"create\",", + " \"kind\": \"delete\",", " \"complete\": {", - " \"archived\": true,", " \"id\": \"abc-archived\",", " \"type\": \"person\",", - " \"created_by\": \"00000000-0000-0000-0000-000000000000\",", - " \"created_at\": \"{{timestamp}}\",", + " \"first_name\": \"ArchivedFirst\",", + " \"last_name\": \"ArchivedLast\",", + " \"archived\": true,", " \"modified_by\": \"00000000-0000-0000-0000-000000000000\",", " \"modified_at\": \"{{timestamp}}\"", " },", " \"new\": {", " \"archived\": true,", " \"type\": \"person\"", + " },", + " \"old\": {", + " \"archived\": false", " }", "}'))" ] @@ -4027,6 +3772,143 @@ ] ] } + }, + { + "description": "Existence probes hit the same row via id and lookup key (dedup, updates)", + "action": "merge", + "data": { + "id": "22222222-2222-2222-2222-222222222222", + "type": "person", + "first_name": "LookupFirst", + "last_name": "LookupLast", + "date_of_birth": "1990-01-01T00:00:00Z", + "pronouns": "they/them", + "contact_id": "new-contact" + }, + "mocks": [ + { + "id": "22222222-2222-2222-2222-222222222222", + "type": "person", + "first_name": "LookupFirst", + "last_name": "LookupLast", + "date_of_birth": "1990-01-01T00:00:00Z", + "pronouns": "they/them", + "contact_id": "old-contact" + } + ], + "schema_id": "person", + "expect": { + "success": true, + "sql": [ + [ + "(SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*) FROM agreego.\"entity\" t1 LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id WHERE t1.id = '{{uuid:mocks.0.id}}' UNION SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*) FROM agreego.\"entity\" t1 LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id WHERE (\"first_name\" = 'LookupFirst' AND \"last_name\" = 'LookupLast' AND \"date_of_birth\" = '{{timestamp}}' AND \"pronouns\" = 'they/them'))" + ], + [ + "UPDATE agreego.\"entity\" SET", + " modified_at = '{{timestamp}}',", + " modified_by = '00000000-0000-0000-0000-000000000000'", + "WHERE", + " id = '{{uuid:mocks.0.id}}'" + ], + [ + "UPDATE agreego.\"person\" SET", + " contact_id = 'new-contact'", + "WHERE", + " id = '{{uuid:mocks.0.id}}'" + ], + [ + "INSERT INTO agreego.change (", + " \"old\",", + " \"new\",", + " \"entity_id\",", + " \"id\",", + " \"kind\",", + " \"modified_at\",", + " \"modified_by\"", + ")", + "VALUES (", + " '{", + " \"contact_id\": \"old-contact\"", + " }',", + " '{", + " \"contact_id\": \"new-contact\",", + " \"type\": \"person\"", + " }',", + " '{{uuid:mocks.0.id}}',", + " '{{uuid:generated_0}}',", + " 'update',", + " '{{timestamp}}',", + " '00000000-0000-0000-0000-000000000000'", + ")" + ], + [ + "(SELECT pg_notify('entity', '{", + " \"kind\": \"update\",", + " \"complete\": {", + " \"id\": \"{{uuid:mocks.0.id}}\",", + " \"type\": \"person\",", + " \"first_name\": \"LookupFirst\",", + " \"last_name\": \"LookupLast\",", + " \"date_of_birth\": \"{{timestamp}}\",", + " \"pronouns\": \"they/them\",", + " \"contact_id\": \"new-contact\",", + " \"modified_by\": \"00000000-0000-0000-0000-000000000000\",", + " \"modified_at\": \"{{timestamp}}\"", + " },", + " \"new\": {", + " \"contact_id\": \"new-contact\",", + " \"type\": \"person\"", + " },", + " \"old\": {", + " \"contact_id\": \"old-contact\"", + " }", + "}'))" + ] + ] + } + }, + { + "description": "Id probe and lookup probe find different rows (TOO_MANY_LOOKUP_ROWS)", + "action": "merge", + "data": { + "id": "22222222-2222-2222-2222-222222222222", + "type": "person", + "first_name": "OtherFirst", + "last_name": "OtherLast", + "date_of_birth": "1991-02-02T00:00:00Z", + "pronouns": "she/her" + }, + "mocks": [ + { + "id": "22222222-2222-2222-2222-222222222222", + "type": "person", + "first_name": "LookupFirst", + "last_name": "LookupLast", + "date_of_birth": "1990-01-01T00:00:00Z", + "pronouns": "they/them" + }, + { + "id": "44444444-4444-4444-4444-444444444444", + "type": "person", + "first_name": "OtherFirst", + "last_name": "OtherLast", + "date_of_birth": "1991-02-02T00:00:00Z", + "pronouns": "she/her" + } + ], + "schema_id": "person", + "expect": { + "success": false, + "errors": [ + { + "code": "MERGE_FAILED", + "values": { + "error": "TOO_MANY_LOOKUP_ROWS: Lookup for person found too many existing rows" + }, + "details": {} + } + ] + } } ] } diff --git a/src/database/executors/mock.rs b/src/database/executors/mock.rs index 4a037b6..92e6c3d 100644 --- a/src/database/executors/mock.rs +++ b/src/database/executors/mock.rs @@ -116,99 +116,17 @@ fn parse_and_match_mocks(sql: &str, mocks: &[Value]) -> Option> { return None; } - // 1. Extract table name - let table_regex = Regex::new(r#"(?i)\s+FROM\s+(?:[a-zA-Z_]\w*\.)?"?([a-zA-Z_]\w*)"?"#).ok()?; - let table = if let Some(caps) = table_regex.captures(sql) { - caps.get(1)?.as_str() - } else { - return None; - }; + // The merger's existence read emits one SELECT per probe (id / lookup key) + // joined by UNION. Match each arm independently against its own tables and + // WHERE clause; a mock matching any arm is returned once, mirroring UNION's + // dedup semantics. + let union_regex = Regex::new(r"(?i)\s+UNION\s+(?:ALL\s+)?").ok()?; + let arms: Vec<&str> = union_regex.split(sql).collect(); - // 2. Extract WHERE conditions string - let mut where_clause = String::new(); - if let Some(where_idx) = sql_upper.find(" WHERE ") { - let mut where_end = sql_upper.find(" ORDER BY ").unwrap_or(sql_upper.len()); - if let Some(limit_idx) = sql_upper.find(" LIMIT ") { - if limit_idx < where_end { - where_end = limit_idx; - } - } - where_clause = sql[where_idx + 7..where_end].to_string(); - } - - // 3. Find matching mocks let mut matches = Vec::new(); - let or_regex = Regex::new(r"(?i)\s+OR\s+").ok()?; - let and_regex = Regex::new(r"(?i)\s+AND\s+").ok()?; - for mock in mocks { if let Some(mock_obj) = mock.as_object() { - if let Some(t) = mock_obj.get("type") { - if t.as_str() != Some(table) { - continue; - } - } - - if where_clause.is_empty() { - matches.push(mock.clone()); - continue; - } - - let or_parts = or_regex.split(&where_clause); - let mut any_branch_matched = false; - - for or_part in or_parts { - let branch_str = or_part.replace('(', "").replace(')', ""); - let mut branch_matches = true; - - for part in and_regex.split(&branch_str) { - if let Some(eq_idx) = part.find('=') { - let left = part[..eq_idx] - .trim() - .split('.') - .last() - .unwrap_or("") - .trim_matches('"'); - let right = part[eq_idx + 1..].trim().trim_matches('\''); - - let mock_val_str = match mock_obj.get(left) { - Some(Value::String(s)) => s.clone(), - Some(Value::Number(n)) => n.to_string(), - Some(Value::Bool(b)) => b.to_string(), - Some(Value::Null) => "null".to_string(), - _ => "".to_string(), - }; - if mock_val_str != right { - branch_matches = false; - break; - } - } else if part.to_uppercase().contains(" IS NULL") { - let left = part[..part.to_uppercase().find(" IS NULL").unwrap()] - .trim() - .split('.') - .last() - .unwrap_or("") - .trim_matches('"'); - - let mock_val_str = match mock_obj.get(left) { - Some(Value::Null) => "null".to_string(), - _ => "".to_string(), - }; - - if mock_val_str != "null" { - branch_matches = false; - break; - } - } - } - - if branch_matches { - any_branch_matched = true; - break; - } - } - - if any_branch_matched { + if arms.iter().any(|arm| arm_matches_mock(arm, mock_obj)) { matches.push(mock.clone()); } } @@ -216,3 +134,103 @@ fn parse_and_match_mocks(sql: &str, mocks: &[Value]) -> Option> { Some(matches) } + +#[cfg(test)] +fn arm_matches_mock(arm: &str, mock_obj: &serde_json::Map) -> bool { + let arm_upper = arm.to_uppercase(); + + // 1. The mock's type must name one of the arm's tables. Hierarchy reads + // SELECT FROM the root (entity) and JOIN the subtype tables, so every + // FROM/JOIN table counts, not just the first. + let table_regex = match Regex::new(r#"(?i)\b(?:FROM|JOIN)\s+(?:[a-zA-Z_]\w*\.)?"?([a-zA-Z_]\w*)"?"#) { + Ok(r) => r, + Err(_) => return false, + }; + let tables: Vec<&str> = table_regex.captures_iter(arm).filter_map(|c| c.get(1)).map(|m| m.as_str()).collect(); + if tables.is_empty() { + return false; + } + if let Some(t) = mock_obj.get("type") { + if !tables.iter().any(|table| t.as_str() == Some(table)) { + return false; + } + } + + // 2. Extract this arm's WHERE conditions + let mut where_clause = String::new(); + if let Some(where_idx) = arm_upper.find(" WHERE ") { + let mut where_end = arm_upper.find(" ORDER BY ").unwrap_or(arm_upper.len()); + if let Some(limit_idx) = arm_upper.find(" LIMIT ") { + if limit_idx < where_end { + where_end = limit_idx; + } + } + where_clause = arm[where_idx + 7..where_end].to_string(); + } + + if where_clause.is_empty() { + return true; + } + + // 3. Match the mock against the conditions + let or_regex = match Regex::new(r"(?i)\s+OR\s+") { + Ok(r) => r, + Err(_) => return false, + }; + let and_regex = match Regex::new(r"(?i)\s+AND\s+") { + Ok(r) => r, + Err(_) => return false, + }; + + for or_part in or_regex.split(&where_clause) { + let branch_str = or_part.replace('(', "").replace(')', ""); + let mut branch_matches = true; + + for part in and_regex.split(&branch_str) { + if let Some(eq_idx) = part.find('=') { + let left = part[..eq_idx] + .trim() + .split('.') + .last() + .unwrap_or("") + .trim_matches('"'); + let right = part[eq_idx + 1..].trim().trim_matches('\''); + + let mock_val_str = match mock_obj.get(left) { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.to_string(), + Some(Value::Bool(b)) => b.to_string(), + Some(Value::Null) => "null".to_string(), + _ => "".to_string(), + }; + if mock_val_str != right { + branch_matches = false; + break; + } + } else if part.to_uppercase().contains(" IS NULL") { + let left = part[..part.to_uppercase().find(" IS NULL").unwrap()] + .trim() + .split('.') + .last() + .unwrap_or("") + .trim_matches('"'); + + let mock_val_str = match mock_obj.get(left) { + Some(Value::Null) => "null".to_string(), + _ => "".to_string(), + }; + + if mock_val_str != "null" { + branch_matches = false; + break; + } + } + } + + if branch_matches { + return true; + } + } + + false +} diff --git a/src/tests/fixtures.rs b/src/tests/fixtures.rs index b22d10c..9c854fa 100644 --- a/src/tests/fixtures.rs +++ b/src/tests/fixtures.rs @@ -7660,3 +7660,15 @@ fn test_merger_0_15() { let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR")); crate::tests::runner::run_test_case(&path, 0, 15).unwrap(); } + +#[test] +fn test_merger_0_16() { + let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR")); + crate::tests::runner::run_test_case(&path, 0, 16).unwrap(); +} + +#[test] +fn test_merger_0_17() { + let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR")); + crate::tests::runner::run_test_case(&path, 0, 17).unwrap(); +}