mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
fix(ownership): correct changes, alias search, graph, and name lookups
From a full-repo Codex audit, each reproduced on the synced ownership DB:
- `changes` paired an investor's accounts across releases instead of
comparing totals: KREN's KRESNA PRIMA INVEST went 9.44% -> 13.45% (a
second 4.01% account) but was reported as decreased 944 -> 401 bps.
Sum a holder's accounts per ticker, and read one release per date (the
latest imported, as other queries do), so a corrected file supersedes
the original instead of mixing with it.
- `changes` / get_ownership_changes accepted any dates: `from: ""`
reported all 7,154 holders as new, unknown dates returned no changes.
Dates must be YYYY-MM-DD imported releases with `from` before `to`.
- get_investor_holdings / `entity` with an empty name matched every
investor and returned an arbitrary one ("AMY"); `%` and `_` acted as
LIKE wildcards. Reject empty names; match wildcards literally.
- Alias search never worked: the FTS table declared an `aliases` column
its content table lacks (`no such column: T.aliases`), so searches
fell back to canonical names and a raw KSEI name such as
"PT ASTRA INTERNATIONAL TBK" found nothing. Search names and aliases
directly, ranked exact name, exact alias, prefix, then shorter names.
- `graph` enumerated every path (exponential in depth) and its cycle
check matched substrings, dropping entity:1 when entity:10 was on the
path. Traverse breadth-first with an exact visited set.
- Name normalization removed leading PT tokens from the front of a Vec,
quadratic in hostile names; narrow a slice instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
parent
f5da3b745e
commit
1d3fe5ca88
6 changed files with 437 additions and 196 deletions
|
|
@ -3,47 +3,50 @@ use rusqlite::{Connection, params};
|
|||
use crate::error::IdxError;
|
||||
use crate::ownership::types::Entity;
|
||||
|
||||
pub fn fts_search(conn: &Connection, query: &str, limit: usize) -> Result<Vec<Entity>, IdxError> {
|
||||
/// Investors whose canonical name or any recorded alias (the raw names KSEI
|
||||
/// prints, such as `PT ASTRA INTERNATIONAL TBK`) contains `query`, ignoring
|
||||
/// ASCII case. Ranked by an exact name, then an exact alias, then a name
|
||||
/// prefix, then shorter names.
|
||||
pub fn search_entities(
|
||||
conn: &Connection,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Entity>, IdxError> {
|
||||
let q = query.trim();
|
||||
if q.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let limit_i64 =
|
||||
let limit =
|
||||
i64::try_from(limit).map_err(|e| IdxError::DatabaseError(format!("invalid limit: {e}")))?;
|
||||
// `%` and `_` in the query are literal characters, not LIKE wildcards.
|
||||
let escaped = q
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_");
|
||||
let contains = format!("%{escaped}%");
|
||||
let prefix = format!("{escaped}%");
|
||||
|
||||
// Ensure entity_fts has content. For external-content FTS table, populate manually.
|
||||
let fts_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entity_fts", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
if fts_count == 0 {
|
||||
let _ = rebuild_fts(conn);
|
||||
}
|
||||
|
||||
if let Ok(rows) = fts_query(conn, q, limit_i64)
|
||||
&& !rows.is_empty()
|
||||
{
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
like_query(conn, q, limit_i64)
|
||||
}
|
||||
|
||||
fn fts_query(conn: &Connection, query: &str, limit: i64) -> Result<Vec<Entity>, IdxError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT e.id, e.canonical_name, e.entity_type, e.country
|
||||
FROM entity_fts f
|
||||
JOIN entities e ON e.id = f.rowid
|
||||
WHERE entity_fts MATCH ?1
|
||||
ORDER BY rank
|
||||
LIMIT ?2",
|
||||
"SELECT e.id, e.canonical_name, e.entity_type, e.country,
|
||||
MIN(CASE
|
||||
WHEN UPPER(e.canonical_name) = UPPER(?1) THEN 0
|
||||
WHEN UPPER(a.raw_name) = UPPER(?1) THEN 1
|
||||
WHEN e.canonical_name LIKE ?3 ESCAPE '\\' THEN 2
|
||||
ELSE 3
|
||||
END) AS match_rank
|
||||
FROM entities e
|
||||
LEFT JOIN entity_aliases a ON a.entity_id = e.id
|
||||
WHERE e.canonical_name LIKE ?2 ESCAPE '\\'
|
||||
OR a.raw_name LIKE ?2 ESCAPE '\\'
|
||||
GROUP BY e.id
|
||||
ORDER BY match_rank, LENGTH(e.canonical_name), e.canonical_name
|
||||
LIMIT ?4",
|
||||
)
|
||||
.map_err(|e| IdxError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![query, limit], |row| {
|
||||
.query_map(params![q, contains, prefix, limit], |row| {
|
||||
Ok(Entity {
|
||||
id: row.get(0)?,
|
||||
canonical_name: row.get(1)?,
|
||||
|
|
@ -53,59 +56,80 @@ fn fts_query(conn: &Connection, query: &str, limit: i64) -> Result<Vec<Entity>,
|
|||
})
|
||||
.map_err(|e| IdxError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row.map_err(|e| IdxError::DatabaseError(e.to_string()))?);
|
||||
rows.map(|row| row.map_err(|e| IdxError::DatabaseError(e.to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use super::search_entities;
|
||||
use crate::ownership::db::ensure_schema;
|
||||
|
||||
fn conn_with(entities: &[(&str, &[&str])]) -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
ensure_schema(&conn).unwrap();
|
||||
for (name, aliases) in entities {
|
||||
conn.execute(
|
||||
"INSERT INTO entities (canonical_name, created_at, updated_at) VALUES (?1, 0, 0)",
|
||||
params![name],
|
||||
)
|
||||
.unwrap();
|
||||
let id = conn.last_insert_rowid();
|
||||
for alias in *aliases {
|
||||
conn.execute(
|
||||
"INSERT INTO entity_aliases (entity_id, raw_name, source, method)
|
||||
VALUES (?1, ?2, 'ksei', 'exact')",
|
||||
params![id, alias],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
conn
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn like_query(conn: &Connection, query: &str, limit: i64) -> Result<Vec<Entity>, IdxError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, canonical_name, entity_type, country
|
||||
FROM entities
|
||||
WHERE canonical_name LIKE ?1 COLLATE NOCASE
|
||||
ORDER BY canonical_name ASC
|
||||
LIMIT ?2",
|
||||
)
|
||||
.map_err(|e| IdxError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let like = format!("%{query}%");
|
||||
let rows = stmt
|
||||
.query_map(params![like, limit], |row| {
|
||||
Ok(Entity {
|
||||
id: row.get(0)?,
|
||||
canonical_name: row.get(1)?,
|
||||
entity_type: row.get(2)?,
|
||||
country: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| IdxError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row.map_err(|e| IdxError::DatabaseError(e.to_string()))?);
|
||||
fn names(conn: &Connection, query: &str) -> Vec<String> {
|
||||
search_entities(conn, query, 10)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| e.canonical_name)
|
||||
.collect()
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn rebuild_fts(conn: &Connection) -> Result<(), IdxError> {
|
||||
conn.execute("DELETE FROM entity_fts", [])
|
||||
.map_err(|e| IdxError::DatabaseError(e.to_string()))?;
|
||||
#[test]
|
||||
fn finds_investors_by_alias() {
|
||||
let conn = conn_with(&[
|
||||
("ASTRA INTERNATIONAL", &["PT ASTRA INTERNATIONAL TBK"]),
|
||||
("ASTRA OTOPARTS", &[]),
|
||||
]);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO entity_fts(rowid, canonical_name, aliases)
|
||||
SELECT e.id, e.canonical_name, COALESCE(a.aliases, '')
|
||||
FROM entities e
|
||||
LEFT JOIN (
|
||||
SELECT entity_id, GROUP_CONCAT(raw_name, ' ') AS aliases
|
||||
FROM entity_aliases
|
||||
GROUP BY entity_id
|
||||
) a ON a.entity_id = e.id",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| IdxError::DatabaseError(e.to_string()))?;
|
||||
assert_eq!(
|
||||
names(&conn, "pt astra international tbk"),
|
||||
["ASTRA INTERNATIONAL"]
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
#[test]
|
||||
fn ranks_exact_then_prefix_then_shorter_and_lists_each_once() {
|
||||
let conn = conn_with(&[
|
||||
("SALIM GROUP HOLDINGS", &["PT SALIM", "SALIM"]),
|
||||
("ANTHONI SALIM", &[]),
|
||||
("SALIM", &[]),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
names(&conn, "salim"),
|
||||
["SALIM", "SALIM GROUP HOLDINGS", "ANTHONI SALIM"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn treats_like_wildcards_literally_and_empty_queries_as_no_match() {
|
||||
let conn = conn_with(&[("ALPHA_FUND", &[]), ("ALPHAXFUND", &[]), ("BETA 100%", &[])]);
|
||||
|
||||
assert_eq!(names(&conn, "alpha_fund"), ["ALPHA_FUND"]);
|
||||
assert_eq!(names(&conn, "100%"), ["BETA 100%"]);
|
||||
assert!(names(&conn, " ").is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue