Skip to content

Commit b06f58e

Browse files
authored
Merge pull request #199 from inutano/add-fts-search
Add server-side full-text experiment search with SQLite FTS5
2 parents 48147f7 + 0c61f6f commit b06f58e

8 files changed

Lines changed: 438 additions & 1 deletion

File tree

app.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def self.download_json_with_fallback(remote_url, local_filename)
7373
set :bedsizes, PJ::Bedsize.dump
7474
set :experiment_list, download_json_with_fallback("https://chip-atlas.dbcls.jp/data/metadata/ExperimentList.json", "ExperimentList.json")
7575
set :experiment_list_adv, download_json_with_fallback("https://chip-atlas.dbcls.jp/data/metadata/ExperimentList_adv.json", "ExperimentList_adv.json")
76+
PJ::ExperimentSearch.load_from_json(settings.experiment_list_adv)
7677
set :gsm_to_srx, Hash[settings.experiment_list["data"].map{|a| [a[2], a[0]] }]
7778
set :wabi_endpoint, "https://dtn1.ddbj.nig.ac.jp/wabi/chipatlas/"
7879
rescue ActiveRecord::StatementInvalid
@@ -178,6 +179,15 @@ def self.download_json_with_fallback(remote_url, local_filename)
178179
JSON(data)
179180
end
180181

182+
get '/data/search' do
183+
query = params[:q]
184+
genome = params[:genome]
185+
limit = (params[:limit] || 20).to_i.clamp(1, 100)
186+
data = PJ::ExperimentSearch.search(query, genome: genome, limit: limit)
187+
content_type "application/json"
188+
JSON(data)
189+
end
190+
181191
get "/health" do
182192
checks = {}
183193

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class CreateExperimentsFts < ActiveRecord::Migration[4.2]
2+
def up
3+
execute <<-SQL
4+
CREATE VIRTUAL TABLE IF NOT EXISTS experiments_fts USING fts5(
5+
expid, sra_id, geo_id, genome, agClass, agSubClass,
6+
clClass, clSubClass, title, attributes
7+
)
8+
SQL
9+
end
10+
11+
def down
12+
execute "DROP TABLE IF EXISTS experiments_fts"
13+
end
14+
end

lib/pj.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
require 'pj/bedfile'
33
require 'pj/bedsize'
44
require 'pj/experiment'
5+
require 'pj/experiment_search'
56
require 'pj/fastqc'
67
require 'pj/location'
78
require 'pj/metadata'

lib/pj/experiment_search.rb

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
require 'sinatra/activerecord'
2+
3+
module PJ
4+
module ExperimentSearch
5+
COLUMNS = %w[expid sra_id geo_id genome agClass agSubClass clClass clSubClass title attributes].freeze
6+
7+
class << self
8+
def load_from_json(json_data)
9+
rows = json_data["data"]
10+
return if rows.nil? || rows.empty?
11+
12+
db = ActiveRecord::Base.connection
13+
14+
db.execute("DELETE FROM experiments_fts")
15+
16+
# Bulk insert in batches
17+
rows.each_slice(500) do |batch|
18+
placeholders = batch.map { "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" }.join(", ")
19+
values = batch.flat_map do |row|
20+
COLUMNS.each_with_index.map { |_, i| row[i] || "" }
21+
end
22+
23+
db.exec_insert(
24+
"INSERT INTO experiments_fts (#{COLUMNS.join(', ')}) VALUES #{placeholders}",
25+
"FTS Insert",
26+
values.each_with_index.map { |v, i| [nil, v] }
27+
)
28+
end
29+
30+
puts "ExperimentSearch: loaded #{rows.size} rows into FTS5 table"
31+
end
32+
33+
def search(query, genome: nil, limit: 20)
34+
return { total: 0, returned: 0, experiments: [] } if query.nil? || query.strip.empty?
35+
36+
db = ActiveRecord::Base.connection
37+
38+
# Escape special FTS5 characters and build match expression
39+
sanitized = fts5_sanitize(query)
40+
41+
where_clause = "experiments_fts MATCH ?"
42+
bind_values = [sanitized]
43+
44+
if genome && !genome.empty?
45+
where_clause += " AND genome = ?"
46+
bind_values << genome
47+
end
48+
49+
# Count total matches
50+
count_sql = "SELECT COUNT(*) FROM experiments_fts WHERE #{where_clause}"
51+
total = db.select_value(count_sql, "FTS Count", bind_values.map { |v| [nil, v] }).to_i
52+
53+
# Fetch ranked results
54+
select_sql = <<-SQL
55+
SELECT expid, sra_id, geo_id, genome, agClass, agSubClass,
56+
clClass, clSubClass, title, attributes,
57+
rank
58+
FROM experiments_fts
59+
WHERE #{where_clause}
60+
ORDER BY rank
61+
LIMIT ?
62+
SQL
63+
64+
rows = db.select_all(
65+
select_sql,
66+
"FTS Search",
67+
(bind_values + [limit]).map { |v| [nil, v] }
68+
)
69+
70+
experiments = rows.map do |row|
71+
{
72+
expid: row["expid"],
73+
sra_id: row["sra_id"],
74+
geo_id: row["geo_id"],
75+
genome: row["genome"],
76+
agClass: row["agClass"],
77+
agSubClass: row["agSubClass"],
78+
clClass: row["clClass"],
79+
clSubClass: row["clSubClass"],
80+
title: row["title"],
81+
attributes: row["attributes"]
82+
}
83+
end
84+
85+
{ total: total, returned: experiments.size, experiments: experiments }
86+
end
87+
88+
private
89+
90+
def fts5_sanitize(query)
91+
# Remove FTS5 special characters, then wrap each token in quotes for safety
92+
tokens = query.strip.split(/\s+/).map do |token|
93+
# Strip special FTS5 operators
94+
cleaned = token.gsub(/["()*^{}:]/, "")
95+
next nil if cleaned.empty?
96+
%("#{cleaned}")
97+
end.compact
98+
99+
tokens.join(" ")
100+
end
101+
end
102+
end
103+
end

lib/tasks/metadata.rake

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,25 @@ namespace :metadata do
6363
:load_bedfile,
6464
:load_analysis,
6565
:load_bedsize,
66-
:load_run
66+
:load_run,
67+
:load_fts
6768
] do
6869
puts "All metadata loading completed successfully!"
6970
end
7071

72+
task :load_fts do
73+
puts "[6/6] Loading FTS5 search index..."
74+
start_time = Time.now
75+
json_path = File.join(PROJ_ROOT, "public", "ExperimentList_adv.json")
76+
if File.exist?(json_path)
77+
json_data = JSON.parse(File.read(json_path))
78+
PJ::ExperimentSearch.load_from_json(json_data)
79+
puts " FTS5 index loaded (#{sprintf('%.2f', Time.now - start_time)}s)"
80+
else
81+
puts " Skipping FTS5: ExperimentList_adv.json not found (will be populated at app startup)"
82+
end
83+
end
84+
7185
task :load_experiment => experiment_table_fpath do |t|
7286
puts "[1/5] Loading experiments data..."
7387
start_time = Time.now

mcp/dist/client.d.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export declare class ChipAtlasClient {
2+
private baseUrl;
3+
constructor(baseUrl?: string);
4+
private fetchJson;
5+
private postJson;
6+
listGenomes(): Promise<string[]>;
7+
listExperimentTypes(genome?: string, clClass?: string): Promise<{
8+
id: string;
9+
label: string;
10+
count?: number;
11+
}[]>;
12+
listSampleTypes(genome: string, agClass: string): Promise<{
13+
id: string;
14+
label: string;
15+
count: number;
16+
}[]>;
17+
listAntigens(genome: string, agClass: string, clClass?: string): Promise<{
18+
id: string;
19+
label: string;
20+
count: number | null;
21+
}[]>;
22+
listCellTypes(genome: string, agClass: string, clClass?: string): Promise<{
23+
id: string;
24+
label: string;
25+
count: number | null;
26+
}[]>;
27+
searchExperiments(query: string, limit?: number, genome?: string): Promise<{
28+
total: number;
29+
returned: number;
30+
experiments: Record<string, string>[];
31+
}>;
32+
getExperiment(expid: string): Promise<Record<string, unknown>[]>;
33+
getColocalization(genome: string): Promise<Record<string, unknown>>;
34+
getTargetGenes(): Promise<Record<string, string[]>>;
35+
getBedUrl(condition: {
36+
genome: string;
37+
agClass: string;
38+
agSubClass?: string;
39+
clClass?: string;
40+
clSubClass?: string;
41+
qval?: string;
42+
}): Promise<{
43+
url: string;
44+
}>;
45+
}

mcp/dist/client.js

Lines changed: 98 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)