Skip to content

Commit

Permalink
feat: species dropdown connected to backend (#100)
Browse files Browse the repository at this point in the history
* feat: change the dropdown to a select

* feat: reduce species down to organism name

* feat: join on integer id

* feat: serial id primary key

* feat: get entry and search entry use joins

* feat: fetch all species for the dropdown

* feat: upload the species name

* fix: name might change

* refac: simplify if someone can edit or not

* feat: delete species

* sh run.sh gen_api

* fix: remove link from method

* fix: cascade
  • Loading branch information
xnought authored Dec 3, 2023
1 parent cee72a6 commit 4935364
Show file tree
Hide file tree
Showing 13 changed files with 270 additions and 145 deletions.
59 changes: 23 additions & 36 deletions backend/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
* Proteins Table
*/
CREATE TABLE proteins (
name text NOT NULL UNIQUE PRIMARY KEY, -- user specified name of the protein (TODO: consider having a string limit)
id serial PRIMARY KEY,
name text NOT NULL UNIQUE, -- user specified name of the protein (TODO: consider having a string limit)
length integer, -- length of amino acid sequence
mass numeric, -- mass in amu/daltons
content bytea, -- stored markdown for the protein article (TODO: consider having a limit to how big this can be)
Expand All @@ -26,20 +27,22 @@ CREATE TABLE proteins (
* Species Table
*/
CREATE TABLE species (
id text NOT NULL UNIQUE PRIMARY KEY, -- This should be the first letters in the scientific name - e.g. "gh", the prefix to the files, for simplicity.
tax_genus text NOT NULL,
tax_species text NOT NULL,
scientific_name text UNIQUE GENERATED ALWAYS AS (tax_genus || ' ' || tax_species) STORED,
content bytea
id serial PRIMARY KEY,
name text NOT NULL UNIQUE -- combined genus and species name, provided for now by the user
-- -- removed now to reduce complexity for v0
-- tax_genus text NOT NULL,
-- tax_species text NOT NULL,
-- scientific_name text UNIQUE GENERATED ALWAYS AS (tax_genus || ' ' || tax_species) STORED,
-- content bytea
);

/*
* Table: species_proteins
* Description: Join table for N:M connection between Species and Proteins
*/
CREATE TABLE species_proteins (
species_id text references species(id) ON UPDATE CASCADE ON DELETE CASCADE,
protein_id text references proteins(name) ON UPDATE CASCADE ON DELETE CASCADE,
species_id serial references species(id) ON UPDATE CASCADE ON DELETE CASCADE,
protein_id serial references proteins(id) ON UPDATE CASCADE ON DELETE CASCADE,
PRIMARY KEY (species_id, protein_id)
);

Expand Down Expand Up @@ -73,48 +76,32 @@ INSERT INTO proteins (name, length, mass, content, refs) VALUES (
/*
* Inserts example species into species table
*/
INSERT INTO species(id, tax_genus, tax_species, content) VALUES (
'Gh',
'Ganaspis',
'hookeri',
null
);

INSERT INTO species(id, tax_genus, tax_species, content) VALUES (
'Lb',
'Leptopilina',
'boulardi',
null
);
INSERT INTO species(name) VALUES ('ganaspis hookeri');
INSERT INTO species(name) VALUES ('leptopilina boulardi');
INSERT INTO species(name) VALUES ('leptopilina heterotoma');
INSERT INTO species(name) VALUES ('unknown');

INSERT INTO species(id, tax_genus, tax_species, content) VALUES (
'Lh',
'Leptopilina',
'heterotoma',
null
);

/*
/*
* Inserts connections between species and proteins
*/
*/
INSERT INTO species_proteins(species_id, protein_id) VALUES (
'Gh',
'Gh_comp271_c0_seq1'
1, -- 'ganaspis hookeri',
1 -- 'Gh_comp271_c0_seq1'
);

/*
* Inserts connections between species and proteins
*/
INSERT INTO species_proteins(species_id, protein_id) VALUES (
'Lb',
'Lb17_comp535_c2_seq1'
2, -- 'leptopilina boulardi',
2 -- 'Lb17_comp535_c2_seq1'
);

/*
* Inserts connections between species and proteins
*/
INSERT INTO species_proteins(species_id, protein_id) VALUES (
'Lh',
'Lh14_comp2336_c0_seq1'
3, -- 'leptopilina heterotoma',
3 --'Lh14_comp2336_c0_seq1'
);

4 changes: 4 additions & 0 deletions backend/src/api_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class ProteinEntry(CamelModel):
name: str
length: int
mass: float
species_name: str
content: str | None = None
refs: str | None = None

Expand All @@ -37,6 +38,7 @@ class AllEntries(CamelModel):

class UploadBody(CamelModel):
name: str
species_name: str
content: str # markdown content from user
refs: str # references used in content (bibtex form)
pdb_file_str: str
Expand All @@ -56,5 +58,7 @@ class UploadStatus(CamelModel):
class EditBody(CamelModel):
old_name: str # so we can identify the exact row we need to change
new_name: str
old_species_name: str # so we can identify the exact row we need to change
new_species_name: str
new_content: str | None = None
new_refs: str | None = None
128 changes: 95 additions & 33 deletions backend/src/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,40 +40,45 @@ def get_all_entries():
"""
with Database() as db:
try:
entries_sql = db.execute_return(
"""SELECT name, length, mass FROM proteins"""
)
query = """SELECT proteins.name, proteins.length, proteins.mass, species.name as species_name FROM species_proteins
JOIN proteins ON species_proteins.protein_id = proteins.id
JOIN species ON species_proteins.species_id = species.id;"""
entries_sql = db.execute_return(query)
log.warn(entries_sql)

# if we got a result back
if entries_sql is not None:
return [
ProteinEntry(name=name, length=length, mass=mass)
for name, length, mass in entries_sql
ProteinEntry(
name=name, length=length, mass=mass, species_name=species_name
)
for name, length, mass, species_name in entries_sql
]
except Exception as e:
log.error(e)


@app.get("/search-entries/{query:str}", response_model=list[ProteinEntry] | None)
def search_entries(query: str):
@app.get("/search-entries/{protein_name:str}", response_model=list[ProteinEntry] | None)
def search_entries(protein_name: str):
"""Gets a list of protein entries by a search string
Returns: list[ProteinEntry] if found | None if not found
"""
with Database() as db:
try:
entries_sql = db.execute_return(
"""SELECT name, length, mass FROM proteins
WHERE name ILIKE %s""",
[f"%{query}%"],
)
query = """SELECT proteins.name, proteins.length, proteins.mass, species.name as species_name FROM species_proteins
JOIN proteins ON species_proteins.protein_id = proteins.id
JOIN species ON species_proteins.species_id = species.id
WHERE proteins.name ILIKE %s;"""
entries_sql = db.execute_return(query, [f"%{protein_name}%"])
log.warn(entries_sql)

# if we got a result back
if entries_sql is not None:
return [
ProteinEntry(name=name, length=length, mass=mass)
for name, length, mass in entries_sql
ProteinEntry(
name=name, length=length, mass=mass, species_name=species_name
)
for name, length, mass, species_name in entries_sql
]
except Exception as e:
log.error(e)
Expand All @@ -86,18 +91,18 @@ def get_protein_entry(protein_name: str):
"""
with Database() as db:
try:
entry_sql = db.execute_return(
"""SELECT name, length, mass, content, refs FROM proteins
WHERE name = %s""",
[protein_name],
)
query = """SELECT proteins.name, proteins.length, proteins.mass, proteins.content, proteins.refs, species.name as species_name FROM species_proteins
JOIN proteins ON species_proteins.protein_id = proteins.id
JOIN species ON species_proteins.species_id = species.id
WHERE proteins.name = %s;"""
entry_sql = db.execute_return(query, [protein_name])
log.warn(entry_sql)

# if we got a result back
if entry_sql is not None and len(entry_sql) != 0:
# return the only entry
only_returned_entry = entry_sql[0]
name, length, mass, content, refs = only_returned_entry
name, length, mass, content, refs, species_name = only_returned_entry

# if byte arrays are present, decode them into a string
if content is not None:
Expand All @@ -106,7 +111,12 @@ def get_protein_entry(protein_name: str):
refs = bytea_to_str(refs)

return ProteinEntry(
name=name, length=length, mass=mass, content=content, refs=refs
name=name,
length=length,
mass=mass,
content=content,
refs=refs,
species_name=species_name,
)

except Exception as e:
Expand All @@ -117,17 +127,18 @@ def get_protein_entry(protein_name: str):
@app.delete("/protein-entry/{protein_name:str}", response_model=None)
def delete_protein_entry(protein_name: str):
# Todo, have a meaningful error if the delete fails
try:
with Database() as db:
with Database() as db:
# remove protein
try:
db.execute(
"""DELETE FROM proteins
WHERE name = %s""",
[protein_name],
)
# delete the file from the data/ folder
os.remove(pdb_file_name(protein_name))
except Exception as e:
log.error(e)
# delete the file from the data/ folder
os.remove(pdb_file_name(protein_name))
except Exception as e:
log.error(e)


# None return means success
Expand All @@ -148,9 +159,24 @@ def upload_protein_entry(body: UploadBody):
# write to file to data/ folder
with open(pdb_file_name(pdb.name), "w") as f:
f.write(pdb.file_contents)
except Exception:
log.warn("Failed to write to file")
return UploadError.WRITE_ERROR

# save to db
with Database() as db:
# save to db
with Database() as db:
try:
# first add the species if it doesn't exist
db.execute(
"""INSERT INTO species (name) VALUES (%s) ON CONFLICT DO NOTHING;""",
[body.species_name],
)
except Exception:
log.warn("Failed to insert into species table")
return UploadError.QUERY_ERROR

try:
# add the protein itself
db.execute(
"""INSERT INTO proteins (name, length, mass, content, refs) VALUES (%s, %s, %s, %s, %s);""",
[
Expand All @@ -161,8 +187,21 @@ def upload_protein_entry(body: UploadBody):
str_to_bytea(body.refs),
],
)
except Exception:
return UploadError.WRITE_ERROR
except Exception:
log.warn("Failed to insert into proteins table")
return UploadError.QUERY_ERROR

try:
# connect them with the intermediate table
db.execute(
"""INSERT INTO species_proteins (species_id, protein_id)
VALUES ((SELECT id FROM species WHERE name = %s),
(SELECT id FROM proteins WHERE name = %s));""",
[body.species_name, body.name],
)
except Exception:
log.warn("Failed to insert into join table")
return UploadError.QUERY_ERROR


# TODO: add more edits, now only does name and content edits
Expand All @@ -176,6 +215,7 @@ def edit_protein_entry(body: EditBody):
os.rename(pdb_file_name(body.old_name), pdb_file_name(body.new_name))

with Database() as db:
name_changed = False
if body.new_name != body.old_name:
db.execute(
"""UPDATE proteins SET name = %s WHERE name = %s""",
Expand All @@ -184,13 +224,23 @@ def edit_protein_entry(body: EditBody):
body.old_name,
],
)
name_changed = True

if body.new_species_name != body.old_species_name:
db.execute(
"""UPDATE species_proteins SET species_id = (SELECT id FROM species WHERE name = %s) WHERE protein_id = (SELECT id FROM proteins WHERE name = %s)""",
[
body.new_species_name,
body.old_name if not name_changed else body.new_name,
],
)

if body.new_content is not None:
db.execute(
"""UPDATE proteins SET content = %s WHERE name = %s""",
[
str_to_bytea(body.new_content),
body.old_name,
body.old_name if not name_changed else body.new_name,
],
)

Expand All @@ -199,14 +249,26 @@ def edit_protein_entry(body: EditBody):
"""UPDATE proteins SET refs = %s WHERE name = %s""",
[
str_to_bytea(body.new_refs),
body.old_name,
body.old_name if not name_changed else body.new_name,
],
)

except Exception:
return UploadError.WRITE_ERROR


@app.get("/all-species", response_model=list[str] | None)
def get_all_species():
try:
with Database() as db:
query = """SELECT name as species_name FROM species"""
entry_sql = db.execute_return(query)
if entry_sql is not None:
return [d[0] for d in entry_sql]
except Exception:
return


def export_app_for_docker():
"""Needed for the [docker-compose.yml](../../docker-compose.yml)
Example: `uvicorn src.server:export_app_for_docker --reload --host 0.0.0.0`
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/ListProteins.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<Table>
<TableHead>
<TableHeadCell>Protein name</TableHeadCell>
<TableHeadCell>Organism</TableHeadCell>
<TableHeadCell>Length</TableHeadCell>
<TableHeadCell>Mass (Da)</TableHeadCell>
</TableHead>
Expand All @@ -37,6 +38,7 @@
><span class="text-primary-700">{entry.name}</span
></TableBodyCell
>
<TableBodyCell>{entry.speciesName}</TableBodyCell>
<TableBodyCell>{entry.length}</TableBodyCell>
<TableBodyCell>{numberWithCommas(entry.mass)}</TableBodyCell>
</TableBodyRow>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/openapi/models/EditBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
export type EditBody = {
oldName: string;
newName: string;
oldSpeciesName: string;
newSpeciesName: string;
newContent?: (string | null);
newRefs?: (string | null);
};

1 change: 1 addition & 0 deletions frontend/src/openapi/models/HTTPValidationError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ import type { ValidationError } from './ValidationError';
export type HTTPValidationError = {
detail?: Array<ValidationError>;
};

Loading

0 comments on commit 4935364

Please sign in to comment.