Skip to content

Commit a5c7eeb

Browse files
authored
Implementing database views (#56)
* working prototype of database view * adding tests for views and SampleView to schema * expanding view testing * updating documentation with notes on using views * adding missing spectra documentation * fixing indentation error in documentation
1 parent a146a85 commit a5c7eeb

File tree

5 files changed

+238
-1
lines changed

5 files changed

+238
-1
lines changed

astrodbkit2/schema_example.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# Example schema for part of the SIMPLE database
22

3+
import sqlalchemy as sa
34
from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, BigInteger, Enum, Date, DateTime
45
import enum
56
from astrodbkit2.astrodb import Base
7+
from astrodbkit2.views import view
68

79

810
# -------------------------------------------------------------------------------------------------------------------
@@ -82,3 +84,17 @@ class SpectralTypes(Base):
8284
best = Column(Boolean) # flag for indicating if this is the best measurement or not
8385
comments = Column(String(1000))
8486
reference = Column(String(30), ForeignKey('Publications.name', ondelete='cascade'), primary_key=True)
87+
88+
89+
# -------------------------------------------------------------------------------------------------------------------
90+
# Views
91+
SampleView = view(
92+
"SampleView",
93+
Base.metadata,
94+
sa.select(
95+
Sources.source.label("source"),
96+
Sources.ra.label("s_ra"),
97+
Sources.dec.label("s_dec"),
98+
SpectralTypes.spectral_type.label("spectral_type"),
99+
).select_from(Sources).join(SpectralTypes, Sources.source == SpectralTypes.source)
100+
)

astrodbkit2/tests/test_astrodb.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
import pytest
66
import io
77
import pandas as pd
8-
from sqlalchemy import select, func
8+
import sqlalchemy as sa
99
from sqlalchemy.exc import IntegrityError
1010
from astropy.table import Table
1111
from astropy.coordinates import SkyCoord
1212
from astropy.units.quantity import Quantity
1313
from astropy.io import ascii
1414
from astrodbkit2.astrodb import Database, create_database, Base, copy_database_schema
15+
from astrodbkit2.views import view
1516
from astrodbkit2.schema_example import *
1617
try:
1718
import mock
@@ -338,6 +339,50 @@ def test_inventory(db):
338339
assert db.inventory('2MASS J13571237+1428398') == test_dict
339340

340341

342+
def test_views(db):
343+
# Test database views
344+
345+
# Create one manually
346+
PhotView = view(
347+
"PhotView",
348+
db.metadata,
349+
sa.select(
350+
db.Sources.c.source.label("source"),
351+
db.Sources.c.ra.label("s_ra"),
352+
db.Sources.c.dec.label("s_dec"),
353+
db.Photometry.c.band.label("band"),
354+
db.Photometry.c.magnitude.label("value"),
355+
).select_from(db.Sources).join(db.Photometry, db.Sources.c.source == db.Photometry.c.source)
356+
)
357+
# Explicitly create
358+
with db.engine.begin() as conn:
359+
db.metadata.create_all(conn)
360+
361+
# Query the view
362+
t = db.query(PhotView).table()
363+
print(t)
364+
assert len(t) == 3 # 3 Photometry values for the single source
365+
366+
# Query one created in schema file
367+
t = db.query(SampleView).table()
368+
print(t)
369+
assert len(t) == 1
370+
371+
# Test views are listed when inspected
372+
insp = sa.inspect(db.engine)
373+
view_list = insp.get_view_names()
374+
assert 'PhotView' in view_list and 'SampleView' in view_list
375+
376+
# Reflect a view and query it
377+
ViewCopy = sa.Table('SampleView', sa.MetaData())
378+
insp.reflect_table(ViewCopy, include_columns=None)
379+
assert db.query(ViewCopy).count() == 1
380+
381+
# Confirm that views are not used in inventory
382+
assert 'PhotView' not in db.inventory('2MASS J13571237+1428398').keys()
383+
assert 'SampleView' not in db.inventory('2MASS J13571237+1428398').keys()
384+
385+
341386
def test_save_reference_table(db, db_dir):
342387
# Test saving a reference table
343388
if os.path.exists(os.path.join(db_dir, 'Publications.json')):

astrodbkit2/tests/test_views.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Tests for views
2+
# Adapted from https://github.com/sqlalchemy/sqlalchemy/wiki/Views
3+
4+
import sqlalchemy as sa
5+
from sqlalchemy.orm import declarative_base
6+
from sqlalchemy.orm import Session
7+
from astrodbkit2.views import *
8+
9+
10+
def test_views():
11+
engine = sa.create_engine("sqlite://", echo=True)
12+
metadata = sa.MetaData()
13+
stuff = sa.Table(
14+
"stuff",
15+
metadata,
16+
sa.Column("id", sa.Integer, primary_key=True),
17+
sa.Column("data", sa.String(50)),
18+
)
19+
20+
more_stuff = sa.Table(
21+
"more_stuff",
22+
metadata,
23+
sa.Column("id", sa.Integer, primary_key=True),
24+
sa.Column("stuff_id", sa.Integer, sa.ForeignKey("stuff.id")),
25+
sa.Column("data", sa.String(50)),
26+
)
27+
28+
# the .label() is to suit SQLite which needs explicit label names
29+
# to be given when creating the view
30+
# See http://www.sqlite.org/c3ref/column_name.html
31+
stuff_view = view(
32+
"stuff_view",
33+
metadata,
34+
sa.select(
35+
stuff.c.id.label("id"),
36+
stuff.c.data.label("data"),
37+
more_stuff.c.data.label("moredata"),
38+
)
39+
.select_from(stuff.join(more_stuff))
40+
.where(stuff.c.data.like(("%orange%"))),
41+
)
42+
43+
assert stuff_view.primary_key == [stuff_view.c.id]
44+
45+
with engine.begin() as conn:
46+
metadata.create_all(conn)
47+
48+
with engine.begin() as conn:
49+
conn.execute(
50+
stuff.insert(),
51+
[
52+
{"data": "apples"},
53+
{"data": "pears"},
54+
{"data": "oranges"},
55+
{"data": "orange julius"},
56+
{"data": "apple jacks"},
57+
],
58+
)
59+
60+
conn.execute(
61+
more_stuff.insert(),
62+
[
63+
{"stuff_id": 3, "data": "foobar"},
64+
{"stuff_id": 4, "data": "foobar"},
65+
],
66+
)
67+
68+
with engine.connect() as conn:
69+
assert conn.execute(
70+
sa.select(stuff_view.c.data, stuff_view.c.moredata)
71+
).all() == [("oranges", "foobar"), ("orange julius", "foobar")]
72+
73+
# illustrate ORM usage
74+
Base = declarative_base(metadata=metadata)
75+
76+
class MyStuff(Base):
77+
__table__ = stuff_view
78+
79+
def __repr__(self):
80+
return f"MyStuff({self.id!r}, {self.data!r}, {self.moredata!r})"
81+
82+
with Session(engine) as s:
83+
assert s.query(MyStuff).count() == 2

astrodbkit2/views.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Logic to implement and set up views in SQLAlchemy
2+
# Adapted from https://github.com/sqlalchemy/sqlalchemy/wiki/Views
3+
4+
import sqlalchemy as sa
5+
from sqlalchemy.ext import compiler
6+
from sqlalchemy.schema import DDLElement
7+
from sqlalchemy.sql import table
8+
9+
10+
class CreateView(DDLElement):
11+
def __init__(self, name, selectable):
12+
self.name = name
13+
self.selectable = selectable
14+
15+
16+
class DropView(DDLElement):
17+
def __init__(self, name):
18+
self.name = name
19+
20+
21+
@compiler.compiles(CreateView)
22+
def _create_view(element, compiler, **kw):
23+
return "CREATE VIEW %s AS %s" % (
24+
element.name,
25+
compiler.sql_compiler.process(element.selectable, literal_binds=True),
26+
)
27+
28+
29+
@compiler.compiles(DropView)
30+
def _drop_view(element, compiler, **kw):
31+
return "DROP VIEW %s" % (element.name)
32+
33+
34+
def view_exists(ddl, target, connection, **kw):
35+
return ddl.name in sa.inspect(connection).get_view_names()
36+
37+
38+
def view_doesnt_exist(ddl, target, connection, **kw):
39+
return not view_exists(ddl, target, connection, **kw)
40+
41+
42+
def view(name, metadata, selectable):
43+
t = table(name)
44+
45+
t._columns._populate_separate_keys(
46+
col._make_proxy(t) for col in selectable.selected_columns
47+
)
48+
49+
sa.event.listen(
50+
metadata,
51+
"after_create",
52+
CreateView(name, selectable).execute_if(callable_=view_doesnt_exist),
53+
)
54+
sa.event.listen(
55+
metadata, "before_drop", DropView(name).execute_if(callable_=view_exists)
56+
)
57+
return t

docs/index.rst

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,42 @@ for example `$ASTRODB_SPECTRA/infrared/myfile.fits`.
310310
**AstrodbKit2** would examine the environment variable `$ASTRODB_SPECTRA` and use that as
311311
part of the absolute path to the file.
312312

313+
Working With Views
314+
------------------
315+
316+
If your database contains views, they will not be included in the inventory methods or in the output JSON files.
317+
However, you can still work with and query data in them.
318+
The best way to define them is in the schema file, for example, with something like::
319+
320+
SampleView = view(
321+
"SampleView",
322+
Base.metadata,
323+
sa.select(
324+
Sources.source.label("source"),
325+
Sources.ra.label("s_ra"),
326+
Sources.dec.label("s_dec"),
327+
SpectralTypes.spectral_type.label("spectral_type"),
328+
).select_from(Sources).join(SpectralTypes, Sources.source == SpectralTypes.source)
329+
)
330+
331+
When created, the database will contain these views and users can reflect them to access them::
332+
333+
import sqlalchemy as sa
334+
335+
# Inspect the database to get a list of available views
336+
insp = sa.inspect(db.engine)
337+
print(insp.get_view_names())
338+
339+
# Reflect a view and query it
340+
SampleView = sa.Table('SampleView', sa.MetaData())
341+
insp.reflect_table(SampleView, include_columns=None)
342+
db.query(SampleView).table()
343+
344+
It is important that `sa.MetaData()` be used in the `sa.Table()` call as you don't want to modify
345+
the metadata of the actual database.
346+
If you don't do this and instead use `db.metadata`, you may end up with errors in other parts of AstrodbKit2
347+
functionality as the view will be treated as a physical table.
348+
313349
Modifying Data
314350
==============
315351

0 commit comments

Comments
 (0)