starbash 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of starbash might be problematic. Click here for more details.

starbash/database.py CHANGED
@@ -2,11 +2,30 @@ from __future__ import annotations
2
2
 
3
3
  import sqlite3
4
4
  from pathlib import Path
5
- from typing import Any, Optional
5
+ from typing import Any, Optional, NamedTuple
6
6
  from datetime import datetime, timedelta
7
7
  import json
8
+ from typing import TypeAlias
8
9
 
9
10
  from .paths import get_user_data_dir
11
+ from .aliases import normalize_target_name
12
+
13
+ SessionRow: TypeAlias = dict[str, Any]
14
+ ImageRow: TypeAlias = dict[str, Any]
15
+
16
+
17
+ class SearchCondition(NamedTuple):
18
+ """A search condition for database queries.
19
+
20
+ Args:
21
+ column_name: The column name to filter on (e.g., 'i.date_obs', 'r.url')
22
+ comparison_op: The comparison operator (e.g., '=', '>=', '<=', 'LIKE')
23
+ value: The value to compare against
24
+ """
25
+
26
+ column_name: str
27
+ comparison_op: str
28
+ value: Any
10
29
 
11
30
 
12
31
  def get_column_name(k: str) -> str:
@@ -21,10 +40,35 @@ class Database:
21
40
  """SQLite-backed application database.
22
41
 
23
42
  Stores data under the OS-specific user data directory using platformdirs.
43
+
44
+ Tables:
45
+ #1: repos
46
+ A table with one row per repository. Contains only 'id' (primary key) and 'url' (unique).
47
+ The URL identifies the repository root (e.g., 'file:///path/to/repo').
48
+
49
+ #2: images
24
50
  Provides an `images` table for FITS metadata and basic helpers.
25
51
 
26
- The images table stores DATE-OBS and DATE as indexed SQL columns for
27
- efficient date-based queries, while other FITS metadata is stored in JSON.
52
+ The images table stores DATE-OBS, DATE, and IMAGETYP as indexed SQL columns for
53
+ efficient date-based and type-based queries, while other FITS metadata is stored in JSON.
54
+
55
+ The 'path' column contains a path **relative** to the repository root.
56
+ Each image belongs to exactly one repo, linked via the repo_id foreign key.
57
+ The combination of (repo_id, path) is unique.
58
+
59
+ Image retrieval methods (get_image, search_image, all_images) join with the repos
60
+ table to include repo_url in results, allowing callers to reconstruct absolute paths.
61
+
62
+ #3: sessions
63
+ The sessions table has one row per observing session, summarizing key info.
64
+ Sessions are identified by filter, image type, target, telescope, etc, and start/end times.
65
+ They correspond to groups of images taken together during an observing run (e.g.
66
+ session start/end describes the range of images DATE-OBS).
67
+
68
+ Each session also has an image_doc_id field which will point to a representative
69
+ image in the images table. Eventually we'll use joins to add extra info from images to
70
+ the exposed 'session' row.
71
+
28
72
  """
29
73
 
30
74
  EXPTIME_KEY = "EXPTIME"
@@ -35,13 +79,17 @@ class Database:
35
79
  EXPTIME_TOTAL_KEY = "exptime-total"
36
80
  DATE_OBS_KEY = "DATE-OBS"
37
81
  DATE_KEY = "DATE"
38
- IMAGE_DOC_KEY = "image-doc"
82
+ IMAGE_DOC_KEY = "image-doc-id"
39
83
  IMAGETYP_KEY = "IMAGETYP"
40
84
  OBJECT_KEY = "OBJECT"
41
85
  TELESCOP_KEY = "TELESCOP"
86
+ EXPTIME_KEY = "EXPTIME"
87
+ ID_KEY = "id" # for finding any row by its ID
88
+ REPO_URL_KEY = "repo_url"
42
89
 
43
90
  SESSIONS_TABLE = "sessions"
44
91
  IMAGES_TABLE = "images"
92
+ REPOS_TABLE = "repos"
45
93
 
46
94
  def __init__(
47
95
  self,
@@ -64,18 +112,39 @@ class Database:
64
112
  self._init_tables()
65
113
 
66
114
  def _init_tables(self) -> None:
67
- """Create the images and sessions tables if they don't exist."""
115
+ """Create the repos, images and sessions tables if they don't exist."""
68
116
  cursor = self._db.cursor()
69
117
 
70
- # Create images table with DATE-OBS and DATE as indexed columns
118
+ # Create repos table
119
+ cursor.execute(
120
+ f"""
121
+ CREATE TABLE IF NOT EXISTS {self.REPOS_TABLE} (
122
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
123
+ url TEXT UNIQUE NOT NULL
124
+ )
125
+ """
126
+ )
127
+
128
+ # Create index on url for faster lookups
129
+ cursor.execute(
130
+ f"""
131
+ CREATE INDEX IF NOT EXISTS idx_repos_url ON {self.REPOS_TABLE}(url)
132
+ """
133
+ )
134
+
135
+ # Create images table with DATE-OBS, DATE, and IMAGETYP as indexed columns
71
136
  cursor.execute(
72
137
  f"""
73
138
  CREATE TABLE IF NOT EXISTS {self.IMAGES_TABLE} (
74
139
  id INTEGER PRIMARY KEY AUTOINCREMENT,
75
- path TEXT UNIQUE NOT NULL,
140
+ repo_id INTEGER NOT NULL,
141
+ path TEXT NOT NULL,
76
142
  date_obs TEXT,
77
143
  date TEXT,
78
- metadata TEXT NOT NULL
144
+ imagetyp TEXT COLLATE NOCASE,
145
+ metadata TEXT NOT NULL,
146
+ FOREIGN KEY (repo_id) REFERENCES {self.REPOS_TABLE}(id),
147
+ UNIQUE(repo_id, path)
79
148
  )
80
149
  """
81
150
  )
@@ -101,6 +170,13 @@ class Database:
101
170
  """
102
171
  )
103
172
 
173
+ # Create index on imagetyp for efficient image type filtering
174
+ cursor.execute(
175
+ f"""
176
+ CREATE INDEX IF NOT EXISTS idx_images_imagetyp ON {self.IMAGES_TABLE}(imagetyp)
177
+ """
178
+ )
179
+
104
180
  # Create sessions table
105
181
  cursor.execute(
106
182
  f"""
@@ -108,13 +184,15 @@ class Database:
108
184
  id INTEGER PRIMARY KEY AUTOINCREMENT,
109
185
  start TEXT NOT NULL,
110
186
  end TEXT NOT NULL,
111
- filter TEXT NOT NULL,
112
- imagetyp TEXT NOT NULL,
113
- object TEXT NOT NULL,
187
+ filter TEXT COLLATE NOCASE,
188
+ imagetyp TEXT COLLATE NOCASE NOT NULL,
189
+ object TEXT,
114
190
  telescop TEXT NOT NULL,
115
191
  num_images INTEGER NOT NULL,
116
192
  exptime_total REAL NOT NULL,
117
- image_doc_id INTEGER
193
+ exptime REAL NOT NULL,
194
+ image_doc_id INTEGER,
195
+ FOREIGN KEY (image_doc_id) REFERENCES {self.IMAGES_TABLE}(id)
118
196
  )
119
197
  """
120
198
  )
@@ -123,84 +201,204 @@ class Database:
123
201
  cursor.execute(
124
202
  f"""
125
203
  CREATE INDEX IF NOT EXISTS idx_sessions_lookup
126
- ON {self.SESSIONS_TABLE}(filter, imagetyp, object, telescop, start, end)
204
+ ON {self.SESSIONS_TABLE}(filter, imagetyp, object, telescop, exptime, start, end)
127
205
  """
128
206
  )
129
207
 
130
208
  self._db.commit()
131
209
 
210
+ # --- Convenience helpers for common repo operations ---
211
+ def remove_repo(self, url: str) -> None:
212
+ """Remove a repo record by URL.
213
+
214
+ This will cascade delete all images belonging to this repo, and all sessions
215
+ that reference those images via image_doc_id.
216
+
217
+ The relationship is: repos -> images (via repo_id) -> sessions (via image_doc_id).
218
+ Sessions have an image_doc_id field that points to a representative image.
219
+ We delete sessions whose representative image belongs to the repo being deleted.
220
+
221
+ Args:
222
+ url: The repository URL (e.g., 'file:///path/to/repo')
223
+ """
224
+ cursor = self._db.cursor()
225
+
226
+ # Use a 3-way join to find and delete sessions that reference images from this repo
227
+ # repo_url -> repo_id -> images.id -> sessions.image_doc_id
228
+ cursor.execute(
229
+ f"""
230
+ DELETE FROM {self.SESSIONS_TABLE}
231
+ WHERE id IN (
232
+ SELECT s.id
233
+ FROM {self.SESSIONS_TABLE} s
234
+ INNER JOIN {self.IMAGES_TABLE} i ON s.image_doc_id = i.id
235
+ INNER JOIN {self.REPOS_TABLE} r ON i.repo_id = r.id
236
+ WHERE r.url = ?
237
+ )
238
+ """,
239
+ (url,),
240
+ )
241
+
242
+ # Delete all images from this repo (using repo_id from URL)
243
+ cursor.execute(
244
+ f"""
245
+ DELETE FROM {self.IMAGES_TABLE}
246
+ WHERE repo_id = (SELECT id FROM {self.REPOS_TABLE} WHERE url = ?)
247
+ """,
248
+ (url,),
249
+ )
250
+
251
+ # Finally delete the repo itself
252
+ cursor.execute(f"DELETE FROM {self.REPOS_TABLE} WHERE url = ?", (url,))
253
+
254
+ self._db.commit()
255
+
256
+ def upsert_repo(self, url: str) -> int:
257
+ """Insert or update a repo record by unique URL.
258
+
259
+ Args:
260
+ url: The repository URL (e.g., 'file:///path/to/repo')
261
+
262
+ Returns:
263
+ The rowid of the inserted/updated record.
264
+ """
265
+ cursor = self._db.cursor()
266
+ cursor.execute(
267
+ f"""
268
+ INSERT INTO {self.REPOS_TABLE} (url) VALUES (?)
269
+ ON CONFLICT(url) DO NOTHING
270
+ """,
271
+ (url,),
272
+ )
273
+
274
+ self._db.commit()
275
+
276
+ # Get the rowid of the inserted/existing record
277
+ cursor.execute(f"SELECT id FROM {self.REPOS_TABLE} WHERE url = ?", (url,))
278
+ result = cursor.fetchone()
279
+ if result:
280
+ return result[0]
281
+ return cursor.lastrowid if cursor.lastrowid is not None else 0
282
+
283
+ def get_repo_id(self, url: str) -> int | None:
284
+ """Get the repo_id for a given URL.
285
+
286
+ Args:
287
+ url: The repository URL
288
+
289
+ Returns:
290
+ The repo_id if found, None otherwise
291
+ """
292
+ cursor = self._db.cursor()
293
+ cursor.execute(f"SELECT id FROM {self.REPOS_TABLE} WHERE url = ?", (url,))
294
+ result = cursor.fetchone()
295
+ return result[0] if result else None
296
+
297
+ def get_repo_url(self, repo_id: int) -> str | None:
298
+ """Get the URL for a given repo_id.
299
+
300
+ Args:
301
+ repo_id: The repository ID
302
+
303
+ Returns:
304
+ The URL if found, None otherwise
305
+ """
306
+ cursor = self._db.cursor()
307
+ cursor.execute(f"SELECT url FROM {self.REPOS_TABLE} WHERE id = ?", (repo_id,))
308
+ result = cursor.fetchone()
309
+ return result[0] if result else None
310
+
132
311
  # --- Convenience helpers for common image operations ---
133
- def upsert_image(self, record: dict[str, Any]) -> int:
312
+ def upsert_image(self, record: dict[str, Any], repo_url: str) -> int:
134
313
  """Insert or update an image record by unique path.
135
314
 
136
- The record must include a 'path' key; other keys are arbitrary FITS metadata.
137
- DATE-OBS and DATE are extracted and stored as indexed columns for efficient queries.
138
- Returns the rowid of the inserted/updated record.
315
+ The record must include a 'path' key (relative to repo); other keys are arbitrary FITS metadata.
316
+ The path is stored as-is - caller is responsible for making it relative to the repo.
317
+ DATE-OBS, DATE, and IMAGETYP are extracted and stored as indexed columns for efficient queries.
318
+
319
+ Args:
320
+ record: Dictionary containing image metadata including 'path' (relative to repo)
321
+ repo_url: The repository URL this image belongs to
322
+
323
+ Returns:
324
+ The rowid of the inserted/updated record.
139
325
  """
140
326
  path = record.get("path")
141
327
  if not path:
142
328
  raise ValueError("record must include 'path'")
143
329
 
144
- # Extract date fields for column storage
330
+ # Get or create the repo_id for this URL
331
+ repo_id = self.get_repo_id(repo_url)
332
+ if repo_id is None:
333
+ repo_id = self.upsert_repo(repo_url)
334
+
335
+ # Extract special fields for column storage
145
336
  date_obs = record.get(self.DATE_OBS_KEY)
146
337
  date = record.get(self.DATE_KEY)
338
+ imagetyp = record.get(self.IMAGETYP_KEY)
147
339
 
148
- # Separate path and date fields from metadata
340
+ # Separate path and special fields from metadata
149
341
  metadata = {k: v for k, v in record.items() if k != "path"}
150
342
  metadata_json = json.dumps(metadata)
151
343
 
152
344
  cursor = self._db.cursor()
153
345
  cursor.execute(
154
346
  f"""
155
- INSERT INTO {self.IMAGES_TABLE} (path, date_obs, date, metadata) VALUES (?, ?, ?, ?)
156
- ON CONFLICT(path) DO UPDATE SET
347
+ INSERT INTO {self.IMAGES_TABLE} (repo_id, path, date_obs, date, imagetyp, metadata) VALUES (?, ?, ?, ?, ?, ?)
348
+ ON CONFLICT(repo_id, path) DO UPDATE SET
157
349
  date_obs = excluded.date_obs,
158
350
  date = excluded.date,
351
+ imagetyp = excluded.imagetyp,
159
352
  metadata = excluded.metadata
160
353
  """,
161
- (path, date_obs, date, metadata_json),
354
+ (repo_id, str(path), date_obs, date, imagetyp, metadata_json),
162
355
  )
163
356
 
164
357
  self._db.commit()
165
358
 
166
359
  # Get the rowid of the inserted/updated record
167
- cursor.execute(f"SELECT id FROM {self.IMAGES_TABLE} WHERE path = ?", (path,))
360
+ cursor.execute(
361
+ f"SELECT id FROM {self.IMAGES_TABLE} WHERE repo_id = ? AND path = ?",
362
+ (repo_id, str(path)),
363
+ )
168
364
  result = cursor.fetchone()
169
365
  if result:
170
366
  return result[0]
171
367
  return cursor.lastrowid if cursor.lastrowid is not None else 0
172
368
 
173
- def search_image(self, conditions: dict[str, Any]) -> list[dict[str, Any]] | None:
369
+ def search_image(self, conditions: list[SearchCondition]) -> list[ImageRow]:
174
370
  """Search for images matching the given conditions.
175
371
 
176
372
  Args:
177
- conditions: Dictionary of metadata key-value pairs to match.
178
- Special keys:
179
- - 'date_start': Filter images with DATE-OBS >= this date
180
- - 'date_end': Filter images with DATE-OBS <= this date
373
+ conditions: List of SearchCondition tuples, each containing:
374
+ - column_name: The column to filter on (e.g., 'i.date_obs', 'r.url', 'i.imagetyp')
375
+ - comparison_op: The comparison operator (e.g., '=', '>=', '<=')
376
+ - value: The value to compare against
181
377
 
182
378
  Returns:
183
- List of matching image records or None if no matches
379
+ List of matching image records with relative path, repo_id, and repo_url
380
+
381
+ Example:
382
+ conditions = [
383
+ SearchCondition('r.url', '=', 'file:///path/to/repo'),
384
+ SearchCondition('i.imagetyp', '=', 'BIAS'),
385
+ SearchCondition('i.date_obs', '>=', '2025-01-01'),
386
+ ]
184
387
  """
185
- # Extract special date filter keys (make a copy to avoid modifying caller's dict)
186
- conditions_copy = dict(conditions)
187
- date_start = conditions_copy.pop("date_start", None)
188
- date_end = conditions_copy.pop("date_end", None)
189
-
190
- # Build SQL query with WHERE clauses for date filtering
388
+ # Build SQL query with WHERE clauses from conditions
191
389
  where_clauses = []
192
390
  params = []
193
391
 
194
- if date_start:
195
- where_clauses.append("date_obs >= ?")
196
- params.append(date_start)
392
+ for condition in conditions:
393
+ where_clauses.append(f"{condition.column_name} {condition.comparison_op} ?")
394
+ params.append(condition.value)
197
395
 
198
- if date_end:
199
- where_clauses.append("date_obs <= ?")
200
- params.append(date_end)
201
-
202
- # Build the query
203
- query = f"SELECT id, path, date_obs, date, metadata FROM {self.IMAGES_TABLE}"
396
+ # Build the query with JOIN to repos table
397
+ query = f"""
398
+ SELECT i.id, i.repo_id, i.path, i.date_obs, i.date, i.imagetyp, i.metadata, r.url as repo_url
399
+ FROM {self.IMAGES_TABLE} i
400
+ JOIN {self.REPOS_TABLE} r ON i.repo_id = r.id
401
+ """
204
402
  if where_clauses:
205
403
  query += " WHERE " + " AND ".join(where_clauses)
206
404
 
@@ -210,102 +408,58 @@ class Database:
210
408
  results = []
211
409
  for row in cursor.fetchall():
212
410
  metadata = json.loads(row["metadata"])
411
+ # Store the relative path, repo_id, and repo_url for caller
213
412
  metadata["path"] = row["path"]
413
+ metadata["repo_id"] = row["repo_id"]
414
+ metadata[Database.REPO_URL_KEY] = row[Database.REPO_URL_KEY]
214
415
  metadata["id"] = row["id"]
215
416
 
216
- # Add date fields back to metadata for compatibility
417
+ # Add special fields back to metadata for compatibility
217
418
  if row["date_obs"]:
218
419
  metadata[self.DATE_OBS_KEY] = row["date_obs"]
219
420
  if row["date"]:
220
421
  metadata[self.DATE_KEY] = row["date"]
422
+ if row["imagetyp"]:
423
+ metadata[self.IMAGETYP_KEY] = row["imagetyp"]
221
424
 
222
- # Check if remaining conditions match (those stored in JSON metadata)
223
- match = all(metadata.get(k) == v for k, v in conditions_copy.items())
224
-
225
- if match:
226
- results.append(metadata)
227
-
228
- return results if results else None
229
-
230
- def where_session(self, conditions: dict[str, Any] | None) -> tuple[str, list[Any]]:
231
- """Search for sessions matching the given conditions.
232
-
233
- Args:
234
- conditions: Dictionary of session key-value pairs to match, or None for all.
235
- Special keys:
236
- - 'date_start': Filter sessions starting on or after this date
237
- - 'date_end': Filter sessions starting on or before this date
238
-
239
- Returns:
240
- Tuple of (WHERE clause string, list of parameters)
241
- """
242
- if conditions is None:
243
- conditions = {}
244
-
245
- # Build WHERE clause dynamically based on conditions
246
- where_clauses = []
247
- params = []
248
-
249
- # Extract date range conditions
250
- date_start = conditions.get("date_start")
251
- date_end = conditions.get("date_end")
252
-
253
- # Add date range filters to WHERE clause
254
- if date_start:
255
- where_clauses.append("start >= ?")
256
- params.append(date_start)
257
-
258
- if date_end:
259
- where_clauses.append("start <= ?")
260
- params.append(date_end)
261
-
262
- # Add standard conditions to WHERE clause
263
- for key, value in conditions.items():
264
- if key not in ("date_start", "date_end") and value is not None:
265
- column_name = key
266
- where_clauses.append(f"{column_name} = ?")
267
- params.append(value)
268
-
269
- # Build the query
270
- query = ""
271
-
272
- if where_clauses:
273
- query += " WHERE " + " AND ".join(where_clauses)
425
+ results.append(metadata)
274
426
 
275
- return (query, params)
427
+ return results
276
428
 
277
429
  def search_session(
278
- self, conditions: dict[str, Any] | None = None
279
- ) -> list[dict[str, Any]]:
430
+ self, where_tuple: tuple[str, list[Any]] = ("", [])
431
+ ) -> list[SessionRow]:
280
432
  """Search for sessions matching the given conditions.
281
433
 
282
434
  Args:
283
- conditions: Dictionary of session key-value pairs to match, or None for all.
284
- Special keys:
285
- - 'date_start': Filter sessions starting on or after this date
286
- - 'date_end': Filter sessions starting on or before this date
435
+ where_tuple
287
436
 
288
437
  Returns:
289
- List of matching session records or None
438
+ List of matching session records with metadata from the reference image
290
439
  """
291
- if conditions is None:
292
- conditions = {}
293
-
294
440
  # Build WHERE clause dynamically based on conditions
295
- where_clause, params = self.where_session(conditions)
441
+ where_clause, params = where_tuple
296
442
 
297
- # Build the query
443
+ # Build the query with JOIN to images table to get reference image metadata
298
444
  query = f"""
299
- SELECT id, start, end, filter, imagetyp, object, telescop,
300
- num_images, exptime_total, image_doc_id
301
- FROM {self.SESSIONS_TABLE}
445
+ SELECT s.id, s.start, s.end, s.filter, s.imagetyp, s.object, s.telescop,
446
+ s.num_images, s.exptime_total, s.exptime, s.image_doc_id, i.metadata
447
+ FROM {self.SESSIONS_TABLE} s
448
+ LEFT JOIN {self.IMAGES_TABLE} i ON s.image_doc_id = i.id
302
449
  {where_clause}
303
450
  """
304
451
 
305
452
  cursor = self._db.cursor()
306
453
  cursor.execute(query, params)
307
454
 
308
- results = [dict(row) for row in cursor.fetchall()]
455
+ results = []
456
+ for row in cursor.fetchall():
457
+ session_dict = dict(row)
458
+ # Parse the metadata JSON if it exists
459
+ if session_dict.get("metadata"):
460
+ session_dict["metadata"] = json.loads(session_dict["metadata"])
461
+ results.append(session_dict)
462
+
309
463
  return results
310
464
 
311
465
  def len_table(self, table_name: str) -> int:
@@ -333,48 +487,74 @@ class Database:
333
487
  result = cursor.fetchone()
334
488
  return result[0] if result and result[0] is not None else 0
335
489
 
336
- def get_image(self, path: str) -> dict[str, Any] | None:
337
- """Get an image record by path."""
490
+ def get_image(self, repo_url: str, path: str) -> ImageRow | None:
491
+ """Get an image record by repo_url and relative path.
492
+
493
+ Args:
494
+ repo_url: The repository URL
495
+ path: Path relative to the repository root
496
+
497
+ Returns:
498
+ Image record with relative path, repo_id, and repo_url, or None if not found
499
+ """
338
500
  cursor = self._db.cursor()
339
501
  cursor.execute(
340
- f"SELECT id, path, date_obs, date, metadata FROM {self.IMAGES_TABLE} WHERE path = ?",
341
- (path,),
502
+ f"""
503
+ SELECT i.id, i.repo_id, i.path, i.date_obs, i.date, i.imagetyp, i.metadata, r.url as repo_url
504
+ FROM {self.IMAGES_TABLE} i
505
+ JOIN {self.REPOS_TABLE} r ON i.repo_id = r.id
506
+ WHERE r.url = ? AND i.path = ?
507
+ """,
508
+ (repo_url, path),
342
509
  )
343
- row = cursor.fetchone()
344
510
 
511
+ row = cursor.fetchone()
345
512
  if row is None:
346
513
  return None
347
514
 
348
515
  metadata = json.loads(row["metadata"])
349
516
  metadata["path"] = row["path"]
517
+ metadata["repo_id"] = row["repo_id"]
518
+ metadata[Database.REPO_URL_KEY] = row[Database.REPO_URL_KEY]
350
519
  metadata["id"] = row["id"]
351
520
 
352
- # Add date fields back to metadata for compatibility
521
+ # Add special fields back to metadata for compatibility
353
522
  if row["date_obs"]:
354
523
  metadata[self.DATE_OBS_KEY] = row["date_obs"]
355
524
  if row["date"]:
356
525
  metadata[self.DATE_KEY] = row["date"]
526
+ if row["imagetyp"]:
527
+ metadata[self.IMAGETYP_KEY] = row["imagetyp"]
357
528
 
358
529
  return metadata
359
530
 
360
- def all_images(self) -> list[dict[str, Any]]:
361
- """Return all image records."""
531
+ def all_images(self) -> list[ImageRow]:
532
+ """Return all image records with relative paths, repo_id, and repo_url."""
362
533
  cursor = self._db.cursor()
363
534
  cursor.execute(
364
- f"SELECT id, path, date_obs, date, metadata FROM {self.IMAGES_TABLE}"
535
+ f"""
536
+ SELECT i.id, i.repo_id, i.path, i.date_obs, i.date, i.imagetyp, i.metadata, r.url as repo_url
537
+ FROM {self.IMAGES_TABLE} i
538
+ JOIN {self.REPOS_TABLE} r ON i.repo_id = r.id
539
+ """
365
540
  )
366
541
 
367
542
  results = []
368
543
  for row in cursor.fetchall():
369
544
  metadata = json.loads(row["metadata"])
545
+ # Return relative path, repo_id, and repo_url for caller
370
546
  metadata["path"] = row["path"]
547
+ metadata["repo_id"] = row["repo_id"]
548
+ metadata[Database.REPO_URL_KEY] = row[Database.REPO_URL_KEY]
371
549
  metadata["id"] = row["id"]
372
550
 
373
- # Add date fields back to metadata for compatibility
551
+ # Add special fields back to metadata for compatibility
374
552
  if row["date_obs"]:
375
553
  metadata[self.DATE_OBS_KEY] = row["date_obs"]
376
554
  if row["date"]:
377
555
  metadata[self.DATE_KEY] = row["date"]
556
+ if row["imagetyp"]:
557
+ metadata[self.IMAGETYP_KEY] = row["imagetyp"]
378
558
 
379
559
  results.append(metadata)
380
560
 
@@ -393,7 +573,7 @@ class Database:
393
573
  cursor.execute(
394
574
  f"""
395
575
  SELECT id, start, end, filter, imagetyp, object, telescop,
396
- num_images, exptime_total, image_doc_id
576
+ num_images, exptime_total, exptime, image_doc_id
397
577
  FROM {self.SESSIONS_TABLE}
398
578
  WHERE id = ?
399
579
  """,
@@ -406,21 +586,16 @@ class Database:
406
586
 
407
587
  return dict(row)
408
588
 
409
- def get_session(self, to_find: dict[str, str]) -> dict[str, Any] | None:
589
+ def get_session(self, to_find: dict[str, str]) -> SessionRow | None:
410
590
  """Find a session matching the given criteria.
411
591
 
412
592
  Searches for sessions with the same filter, image type, target, and telescope
413
593
  whose start time is within +/- 8 hours of the provided date.
414
594
  """
415
- date = to_find.get(Database.START_KEY)
595
+ date = to_find.get(get_column_name(Database.START_KEY))
416
596
  assert date
417
- image_type = to_find.get(Database.IMAGETYP_KEY)
597
+ image_type = to_find.get(get_column_name(Database.IMAGETYP_KEY))
418
598
  assert image_type
419
- filter = to_find.get(Database.FILTER_KEY)
420
- assert filter
421
- target = to_find.get(Database.OBJECT_KEY)
422
- assert target
423
- telescop = to_find.get(Database.TELESCOP_KEY, "unspecified")
424
599
 
425
600
  # Convert the provided ISO8601 date string to a datetime, then
426
601
  # search for sessions with the same filter whose start time is
@@ -432,17 +607,75 @@ class Database:
432
607
 
433
608
  # Since session 'start' is stored as ISO8601 strings, lexicographic
434
609
  # comparison aligns with chronological ordering for a uniform format.
610
+
611
+ # Build WHERE clause handling NULL values properly
612
+ # In SQL, you cannot use = with NULL, must use IS NULL
613
+ # If a field is not in to_find, we don't filter on it at all
614
+ where_clauses = []
615
+ params = []
616
+
617
+ # Handle imagetyp (required)
618
+ where_clauses.append("imagetyp = ?")
619
+ params.append(image_type)
620
+
621
+ # Handle filter (optional - only filter if present in to_find)
622
+ filter_key = get_column_name(Database.FILTER_KEY)
623
+ filter = to_find.get(filter_key) # filter can be the string "None"
624
+ if filter:
625
+ if filter is None:
626
+ where_clauses.append("filter IS NULL")
627
+ else:
628
+ where_clauses.append("filter = ?")
629
+ params.append(filter)
630
+
631
+ # Handle object/target (optional - only filter if present in to_find)
632
+ object_key = get_column_name(Database.OBJECT_KEY)
633
+ target = to_find.get(object_key)
634
+ if target:
635
+ target = normalize_target_name(target)
636
+ if target is None:
637
+ where_clauses.append("object IS NULL")
638
+ else:
639
+ where_clauses.append("object = ?")
640
+ params.append(target)
641
+
642
+ # Handle telescop (optional - only filter if present in to_find)
643
+ telescop_key = get_column_name(Database.TELESCOP_KEY)
644
+ telescop = to_find.get(telescop_key)
645
+ if telescop:
646
+ if telescop is None:
647
+ where_clauses.append("telescop IS NULL")
648
+ else:
649
+ where_clauses.append("telescop = ?")
650
+ params.append(telescop)
651
+
652
+ # Handle exptime (optional - only filter if present in to_find)
653
+ exptime_key = get_column_name(Database.EXPTIME_KEY)
654
+ if exptime_key in to_find:
655
+ exptime = to_find.get(exptime_key)
656
+ if exptime is None:
657
+ where_clauses.append("exptime IS NULL")
658
+ else:
659
+ where_clauses.append("exptime = ?")
660
+ params.append(exptime)
661
+
662
+ # Time window
663
+ where_clauses.append("start >= ?")
664
+ where_clauses.append("start <= ?")
665
+ params.extend([start_min, start_max])
666
+
667
+ where_clause = " AND ".join(where_clauses)
668
+
435
669
  cursor = self._db.cursor()
436
670
  cursor.execute(
437
671
  f"""
438
672
  SELECT id, start, end, filter, imagetyp, object, telescop,
439
- num_images, exptime_total, image_doc_id
673
+ num_images, exptime_total, exptime, image_doc_id
440
674
  FROM {self.SESSIONS_TABLE}
441
- WHERE filter = ? AND imagetyp = ? AND object = ? AND telescop = ?
442
- AND start >= ? AND start <= ?
675
+ WHERE {where_clause}
443
676
  LIMIT 1
444
677
  """,
445
- (filter, image_type, target, telescop, start_min, start_max),
678
+ params,
446
679
  )
447
680
 
448
681
  row = cursor.fetchone()
@@ -452,21 +685,27 @@ class Database:
452
685
  return dict(row)
453
686
 
454
687
  def upsert_session(
455
- self, new: dict[str, Any], existing: dict[str, Any] | None = None
688
+ self, new: SessionRow, existing: SessionRow | None = None
456
689
  ) -> None:
457
690
  """Insert or update a session record."""
458
691
  cursor = self._db.cursor()
459
692
 
460
693
  if existing:
461
694
  # Update existing session with new data
462
- updated_start = min(new[Database.START_KEY], existing[Database.START_KEY])
463
- updated_end = max(new[Database.END_KEY], existing[Database.END_KEY])
464
- updated_num_images = existing.get(Database.NUM_IMAGES_KEY, 0) + new.get(
465
- Database.NUM_IMAGES_KEY, 0
695
+ updated_start = min(
696
+ new[get_column_name(Database.START_KEY)],
697
+ existing[get_column_name(Database.START_KEY)],
698
+ )
699
+ updated_end = max(
700
+ new[get_column_name(Database.END_KEY)],
701
+ existing[get_column_name(Database.END_KEY)],
466
702
  )
703
+ updated_num_images = existing.get(
704
+ get_column_name(Database.NUM_IMAGES_KEY), 0
705
+ ) + new.get(get_column_name(Database.NUM_IMAGES_KEY), 0)
467
706
  updated_exptime_total = existing.get(
468
- Database.EXPTIME_TOTAL_KEY, 0
469
- ) + new.get(Database.EXPTIME_TOTAL_KEY, 0)
707
+ get_column_name(Database.EXPTIME_TOTAL_KEY), 0
708
+ ) + new.get(get_column_name(Database.EXPTIME_TOTAL_KEY), 0)
470
709
 
471
710
  cursor.execute(
472
711
  f"""
@@ -487,19 +726,22 @@ class Database:
487
726
  cursor.execute(
488
727
  f"""
489
728
  INSERT INTO {self.SESSIONS_TABLE}
490
- (start, end, filter, imagetyp, object, telescop, num_images, exptime_total, image_doc_id)
491
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
729
+ (start, end, filter, imagetyp, object, telescop, num_images, exptime_total, exptime, image_doc_id)
730
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
492
731
  """,
493
732
  (
494
- new[Database.START_KEY],
495
- new[Database.END_KEY],
496
- new[Database.FILTER_KEY],
497
- new[Database.IMAGETYP_KEY],
498
- new[Database.OBJECT_KEY],
499
- new.get(Database.TELESCOP_KEY, "unspecified"),
500
- new[Database.NUM_IMAGES_KEY],
501
- new[Database.EXPTIME_TOTAL_KEY],
502
- new.get(Database.IMAGE_DOC_KEY),
733
+ new[get_column_name(Database.START_KEY)],
734
+ new[get_column_name(Database.END_KEY)],
735
+ new.get(get_column_name(Database.FILTER_KEY)),
736
+ new[get_column_name(Database.IMAGETYP_KEY)],
737
+ normalize_target_name(
738
+ new.get(get_column_name(Database.OBJECT_KEY))
739
+ ),
740
+ new.get(get_column_name(Database.TELESCOP_KEY)),
741
+ new[get_column_name(Database.NUM_IMAGES_KEY)],
742
+ new[get_column_name(Database.EXPTIME_TOTAL_KEY)],
743
+ new[get_column_name(Database.EXPTIME_KEY)],
744
+ new[get_column_name(Database.IMAGE_DOC_KEY)],
503
745
  ),
504
746
  )
505
747