oehrpy 0.1.0__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.
@@ -0,0 +1,589 @@
1
+ """
2
+ AQL Query Builder implementation.
3
+
4
+ This module provides a fluent API for constructing AQL queries
5
+ with proper escaping and parameterization.
6
+
7
+ AQL Syntax Reference:
8
+ SELECT <select_clause>
9
+ FROM <from_clause>
10
+ [WHERE <where_clause>]
11
+ [ORDER BY <order_by_clause>]
12
+ [LIMIT <limit> [OFFSET <offset>]]
13
+
14
+ Example:
15
+ >>> query = (
16
+ ... AQLBuilder()
17
+ ... .select("c/uid/value")
18
+ ... .from_ehr()
19
+ ... .contains_composition("c")
20
+ ... .build()
21
+ ... )
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from enum import Enum
28
+ from typing import Any
29
+
30
+
31
+ class SortOrder(str, Enum):
32
+ """Sort order for ORDER BY clause."""
33
+
34
+ ASC = "ASC"
35
+ DESC = "DESC"
36
+
37
+
38
+ @dataclass
39
+ class SelectClause:
40
+ """Represents a SELECT clause item."""
41
+
42
+ path: str
43
+ alias: str | None = None
44
+ aggregate: str | None = None # COUNT, MAX, MIN, etc.
45
+
46
+ def to_string(self) -> str:
47
+ """Convert to AQL string."""
48
+ expr = f"{self.aggregate}({self.path})" if self.aggregate else self.path
49
+
50
+ if self.alias:
51
+ return f"{expr} AS {self.alias}"
52
+ return expr
53
+
54
+
55
+ @dataclass
56
+ class FromClause:
57
+ """Represents a FROM clause with containments."""
58
+
59
+ ehr_alias: str = "e"
60
+ ehr_id_param: str | None = None # Parameter name for EHR ID (not the value)
61
+ containments: list[str] = field(default_factory=list)
62
+
63
+ def to_string(self) -> str:
64
+ """Convert to AQL string."""
65
+ parts = []
66
+
67
+ # EHR clause
68
+ if self.ehr_id_param:
69
+ parts.append(f"EHR {self.ehr_alias}[ehr_id/value=:{self.ehr_id_param}]")
70
+ else:
71
+ parts.append(f"EHR {self.ehr_alias}")
72
+
73
+ # Containments
74
+ for containment in self.containments:
75
+ parts.append(f"CONTAINS {containment}")
76
+
77
+ return " ".join(parts)
78
+
79
+
80
+ @dataclass
81
+ class WhereClause:
82
+ """Represents a WHERE clause."""
83
+
84
+ conditions: list[str] = field(default_factory=list)
85
+ logic: str = "AND" # AND or OR
86
+
87
+ def to_string(self) -> str:
88
+ """Convert to AQL string."""
89
+ if not self.conditions:
90
+ return ""
91
+ return f" {self.logic} ".join(self.conditions)
92
+
93
+ def add(self, condition: str) -> None:
94
+ """Add a condition."""
95
+ self.conditions.append(condition)
96
+
97
+
98
+ @dataclass
99
+ class OrderByClause:
100
+ """Represents an ORDER BY clause item."""
101
+
102
+ path: str
103
+ order: SortOrder = SortOrder.ASC
104
+
105
+ def to_string(self) -> str:
106
+ """Convert to AQL string."""
107
+ return f"{self.path} {self.order.value}"
108
+
109
+
110
+ @dataclass
111
+ class AQLQuery:
112
+ """Represents a complete AQL query."""
113
+
114
+ select_clauses: list[SelectClause] = field(default_factory=list)
115
+ from_clause: FromClause | None = None
116
+ where_clause: WhereClause | None = None
117
+ order_by_clauses: list[OrderByClause] = field(default_factory=list)
118
+ limit_value: int | None = None
119
+ offset_value: int | None = None
120
+ parameters: dict[str, Any] = field(default_factory=dict)
121
+
122
+ def to_string(self) -> str:
123
+ """Convert to AQL query string."""
124
+ parts = []
125
+
126
+ # SELECT
127
+ if self.select_clauses:
128
+ select_items = ", ".join(s.to_string() for s in self.select_clauses)
129
+ parts.append(f"SELECT {select_items}")
130
+ else:
131
+ parts.append("SELECT *")
132
+
133
+ # FROM
134
+ if self.from_clause:
135
+ parts.append(f"FROM {self.from_clause.to_string()}")
136
+
137
+ # WHERE
138
+ if self.where_clause and self.where_clause.conditions:
139
+ parts.append(f"WHERE {self.where_clause.to_string()}")
140
+
141
+ # ORDER BY
142
+ if self.order_by_clauses:
143
+ order_items = ", ".join(o.to_string() for o in self.order_by_clauses)
144
+ parts.append(f"ORDER BY {order_items}")
145
+
146
+ # LIMIT and OFFSET
147
+ if self.limit_value is not None:
148
+ parts.append(f"LIMIT {self.limit_value}")
149
+ if self.offset_value is not None:
150
+ parts.append(f"OFFSET {self.offset_value}")
151
+
152
+ return " ".join(parts)
153
+
154
+ def __str__(self) -> str:
155
+ return self.to_string()
156
+
157
+ def with_parameters(self, **params: Any) -> AQLQuery:
158
+ """Return a new query with additional parameters."""
159
+ new_params = {**self.parameters, **params}
160
+ return AQLQuery(
161
+ select_clauses=self.select_clauses,
162
+ from_clause=self.from_clause,
163
+ where_clause=self.where_clause,
164
+ order_by_clauses=self.order_by_clauses,
165
+ limit_value=self.limit_value,
166
+ offset_value=self.offset_value,
167
+ parameters=new_params,
168
+ )
169
+
170
+
171
+ class AQLBuilder:
172
+ """Fluent builder for AQL queries.
173
+
174
+ Example:
175
+ >>> query = (
176
+ ... AQLBuilder()
177
+ ... .select("c/uid/value", alias="uid")
178
+ ... .select("c/context/start_time/value", alias="time")
179
+ ... .from_ehr("e")
180
+ ... .contains_composition("c", "IDCR - Vital Signs Encounter.v1")
181
+ ... .where("e/ehr_id/value = :ehr_id")
182
+ ... .order_by("c/context/start_time/value", descending=True)
183
+ ... .limit(10)
184
+ ... .build()
185
+ ... )
186
+ """
187
+
188
+ def __init__(self) -> None:
189
+ self._select_clauses: list[SelectClause] = []
190
+ self._from_clause: FromClause | None = None
191
+ self._where_clause: WhereClause = WhereClause()
192
+ self._order_by_clauses: list[OrderByClause] = []
193
+ self._limit: int | None = None
194
+ self._offset: int | None = None
195
+ self._parameters: dict[str, Any] = {}
196
+
197
+ def select(
198
+ self,
199
+ path: str,
200
+ alias: str | None = None,
201
+ aggregate: str | None = None,
202
+ ) -> AQLBuilder:
203
+ """Add a SELECT clause item.
204
+
205
+ Args:
206
+ path: The AQL path expression (e.g., "c/uid/value").
207
+ alias: Optional alias for the result column.
208
+ aggregate: Optional aggregate function (COUNT, MAX, MIN, SUM, AVG).
209
+
210
+ Returns:
211
+ Self for method chaining.
212
+ """
213
+ self._select_clauses.append(SelectClause(path, alias, aggregate))
214
+ return self
215
+
216
+ def select_count(self, path: str = "*", alias: str | None = None) -> AQLBuilder:
217
+ """Add a COUNT aggregate."""
218
+ return self.select(path, alias, "COUNT")
219
+
220
+ def select_max(self, path: str, alias: str | None = None) -> AQLBuilder:
221
+ """Add a MAX aggregate."""
222
+ return self.select(path, alias, "MAX")
223
+
224
+ def select_min(self, path: str, alias: str | None = None) -> AQLBuilder:
225
+ """Add a MIN aggregate."""
226
+ return self.select(path, alias, "MIN")
227
+
228
+ def from_ehr(
229
+ self,
230
+ alias: str = "e",
231
+ ehr_id: str | None = None,
232
+ ehr_id_param: str = "ehr_id_from",
233
+ ) -> AQLBuilder:
234
+ """Set the FROM EHR clause.
235
+
236
+ Args:
237
+ alias: Alias for the EHR (default: "e").
238
+ ehr_id: Optional specific EHR ID to query (registered as parameter).
239
+ ehr_id_param: Parameter name for the EHR ID.
240
+
241
+ Returns:
242
+ Self for method chaining.
243
+ """
244
+ if ehr_id is not None:
245
+ self._from_clause = FromClause(ehr_alias=alias, ehr_id_param=ehr_id_param)
246
+ self._parameters[ehr_id_param] = ehr_id
247
+ else:
248
+ self._from_clause = FromClause(ehr_alias=alias)
249
+ return self
250
+
251
+ def contains(
252
+ self,
253
+ rm_type: str,
254
+ alias: str,
255
+ archetype_id: str | None = None,
256
+ ) -> AQLBuilder:
257
+ """Add a CONTAINS clause.
258
+
259
+ Args:
260
+ rm_type: The RM type (COMPOSITION, OBSERVATION, etc.).
261
+ alias: Alias for the contained item.
262
+ archetype_id: Optional archetype ID filter (parameterized).
263
+
264
+ Returns:
265
+ Self for method chaining.
266
+ """
267
+ if self._from_clause is None:
268
+ self._from_clause = FromClause()
269
+
270
+ if archetype_id:
271
+ param_name = f"{alias}_archetype_id"
272
+ containment = f"{rm_type} {alias}[archetype_id/value=:{param_name}]"
273
+ self._parameters[param_name] = archetype_id
274
+ else:
275
+ containment = f"{rm_type} {alias}"
276
+
277
+ self._from_clause.containments.append(containment)
278
+ return self
279
+
280
+ def contains_composition(
281
+ self,
282
+ alias: str = "c",
283
+ template_id: str | None = None,
284
+ archetype_id: str | None = None,
285
+ template_id_param: str = "template_id",
286
+ ) -> AQLBuilder:
287
+ """Add a CONTAINS COMPOSITION clause.
288
+
289
+ Args:
290
+ alias: Alias for the composition.
291
+ template_id: Optional template ID filter (added as parameterized WHERE).
292
+ archetype_id: Optional archetype ID filter.
293
+ template_id_param: Parameter name for template ID.
294
+
295
+ Returns:
296
+ Self for method chaining.
297
+ """
298
+ if self._from_clause is None:
299
+ self._from_clause = FromClause()
300
+
301
+ if archetype_id:
302
+ containment = f"COMPOSITION {alias}[archetype_id/value=:{alias}_archetype_id]"
303
+ self._parameters[f"{alias}_archetype_id"] = archetype_id
304
+ else:
305
+ containment = f"COMPOSITION {alias}"
306
+
307
+ self._from_clause.containments.append(containment)
308
+
309
+ # Add template_id filter as parameterized WHERE clause
310
+ if template_id:
311
+ self._where_clause.add(
312
+ f"{alias}/archetype_details/template_id/value = :{template_id_param}"
313
+ )
314
+ self._parameters[template_id_param] = template_id
315
+
316
+ return self
317
+
318
+ def contains_observation(
319
+ self,
320
+ alias: str = "o",
321
+ archetype_id: str | None = None,
322
+ ) -> AQLBuilder:
323
+ """Add a CONTAINS OBSERVATION clause."""
324
+ return self.contains("OBSERVATION", alias, archetype_id)
325
+
326
+ def contains_evaluation(
327
+ self,
328
+ alias: str = "e",
329
+ archetype_id: str | None = None,
330
+ ) -> AQLBuilder:
331
+ """Add a CONTAINS EVALUATION clause."""
332
+ return self.contains("EVALUATION", alias, archetype_id)
333
+
334
+ def contains_instruction(
335
+ self,
336
+ alias: str = "i",
337
+ archetype_id: str | None = None,
338
+ ) -> AQLBuilder:
339
+ """Add a CONTAINS INSTRUCTION clause."""
340
+ return self.contains("INSTRUCTION", alias, archetype_id)
341
+
342
+ def contains_action(
343
+ self,
344
+ alias: str = "a",
345
+ archetype_id: str | None = None,
346
+ ) -> AQLBuilder:
347
+ """Add a CONTAINS ACTION clause."""
348
+ return self.contains("ACTION", alias, archetype_id)
349
+
350
+ def where(self, condition: str) -> AQLBuilder:
351
+ """Add a WHERE condition.
352
+
353
+ Args:
354
+ condition: The condition expression.
355
+
356
+ Returns:
357
+ Self for method chaining.
358
+ """
359
+ self._where_clause.add(condition)
360
+ return self
361
+
362
+ def and_where(self, condition: str) -> AQLBuilder:
363
+ """Add an AND condition."""
364
+ return self.where(condition)
365
+
366
+ def where_ehr_id(self, ehr_alias: str = "e", param_name: str = "ehr_id") -> AQLBuilder:
367
+ """Add a WHERE condition for EHR ID.
368
+
369
+ Args:
370
+ ehr_alias: Alias for the EHR.
371
+ param_name: Parameter name for the EHR ID.
372
+
373
+ Returns:
374
+ Self for method chaining.
375
+ """
376
+ return self.where(f"{ehr_alias}/ehr_id/value = :{param_name}")
377
+
378
+ def where_template(
379
+ self,
380
+ composition_alias: str = "c",
381
+ template_id: str | None = None,
382
+ param_name: str = "template_id",
383
+ ) -> AQLBuilder:
384
+ """Add a WHERE condition for template ID.
385
+
386
+ Args:
387
+ composition_alias: Alias for the composition.
388
+ template_id: The template ID value (registered as parameter if provided).
389
+ param_name: Parameter name for the template ID.
390
+
391
+ Returns:
392
+ Self for method chaining.
393
+ """
394
+ self.where(f"{composition_alias}/archetype_details/template_id/value = :{param_name}")
395
+ if template_id:
396
+ self._parameters[param_name] = template_id
397
+ return self
398
+
399
+ def where_time_range(
400
+ self,
401
+ path: str,
402
+ start: str | None = None,
403
+ end: str | None = None,
404
+ start_param: str = "start_time",
405
+ end_param: str = "end_time",
406
+ ) -> AQLBuilder:
407
+ """Add WHERE conditions for a time range.
408
+
409
+ Args:
410
+ path: Path to the datetime field.
411
+ start: Start time (ISO format). If provided, registers as parameter.
412
+ end: End time (ISO format). If provided, registers as parameter.
413
+ start_param: Parameter name for start time.
414
+ end_param: Parameter name for end time.
415
+
416
+ Returns:
417
+ Self for method chaining.
418
+ """
419
+ if start:
420
+ self.where(f"{path} >= :{start_param}")
421
+ self._parameters[start_param] = start
422
+ if end:
423
+ self.where(f"{path} <= :{end_param}")
424
+ self._parameters[end_param] = end
425
+ return self
426
+
427
+ def order_by(
428
+ self,
429
+ path: str,
430
+ descending: bool = False,
431
+ ) -> AQLBuilder:
432
+ """Add an ORDER BY clause.
433
+
434
+ Args:
435
+ path: The path to order by.
436
+ descending: Whether to sort descending.
437
+
438
+ Returns:
439
+ Self for method chaining.
440
+ """
441
+ order = SortOrder.DESC if descending else SortOrder.ASC
442
+ self._order_by_clauses.append(OrderByClause(path, order))
443
+ return self
444
+
445
+ def order_by_time(
446
+ self,
447
+ path: str = "c/context/start_time/value",
448
+ descending: bool = True,
449
+ ) -> AQLBuilder:
450
+ """Add ORDER BY for a time field (newest first by default)."""
451
+ return self.order_by(path, descending)
452
+
453
+ def limit(self, count: int) -> AQLBuilder:
454
+ """Set the LIMIT.
455
+
456
+ Args:
457
+ count: Maximum number of results.
458
+
459
+ Returns:
460
+ Self for method chaining.
461
+ """
462
+ self._limit = count
463
+ return self
464
+
465
+ def offset(self, count: int) -> AQLBuilder:
466
+ """Set the OFFSET.
467
+
468
+ Args:
469
+ count: Number of results to skip.
470
+
471
+ Returns:
472
+ Self for method chaining.
473
+ """
474
+ self._offset = count
475
+ return self
476
+
477
+ def paginate(self, page: int, page_size: int) -> AQLBuilder:
478
+ """Set pagination.
479
+
480
+ Args:
481
+ page: Page number (1-based).
482
+ page_size: Number of results per page.
483
+
484
+ Returns:
485
+ Self for method chaining.
486
+ """
487
+ self._limit = page_size
488
+ self._offset = (page - 1) * page_size
489
+ return self
490
+
491
+ def param(self, name: str, value: Any) -> AQLBuilder:
492
+ """Set a query parameter.
493
+
494
+ Args:
495
+ name: Parameter name.
496
+ value: Parameter value.
497
+
498
+ Returns:
499
+ Self for method chaining.
500
+ """
501
+ self._parameters[name] = value
502
+ return self
503
+
504
+ def build(self) -> AQLQuery:
505
+ """Build the AQL query.
506
+
507
+ Returns:
508
+ The constructed AQLQuery object.
509
+ """
510
+ return AQLQuery(
511
+ select_clauses=self._select_clauses,
512
+ from_clause=self._from_clause,
513
+ where_clause=self._where_clause if self._where_clause.conditions else None,
514
+ order_by_clauses=self._order_by_clauses,
515
+ limit_value=self._limit,
516
+ offset_value=self._offset,
517
+ parameters=self._parameters,
518
+ )
519
+
520
+ def to_string(self) -> str:
521
+ """Build and return the query string."""
522
+ return self.build().to_string()
523
+
524
+
525
+ # Convenience functions for common queries
526
+
527
+
528
+ def select_compositions(
529
+ ehr_id: str | None = None,
530
+ template_id: str | None = None,
531
+ limit: int = 100,
532
+ ) -> AQLQuery:
533
+ """Build a query to select compositions.
534
+
535
+ Args:
536
+ ehr_id: Optional EHR ID filter.
537
+ template_id: Optional template ID filter.
538
+ limit: Maximum results.
539
+
540
+ Returns:
541
+ AQLQuery for compositions.
542
+ """
543
+ builder = (
544
+ AQLBuilder()
545
+ .select("c/uid/value", alias="uid")
546
+ .select("c/archetype_details/template_id/value", alias="template_id")
547
+ .select("c/context/start_time/value", alias="start_time")
548
+ .select("c/composer/name", alias="composer")
549
+ .from_ehr()
550
+ .contains_composition()
551
+ )
552
+
553
+ if ehr_id:
554
+ builder.where_ehr_id()
555
+ builder.param("ehr_id", ehr_id)
556
+ if template_id:
557
+ builder.where_template(template_id=template_id)
558
+
559
+ return builder.order_by_time().limit(limit).build()
560
+
561
+
562
+ def select_observations(
563
+ archetype_id: str,
564
+ ehr_id: str | None = None,
565
+ limit: int = 100,
566
+ ) -> AQLQuery:
567
+ """Build a query to select observations.
568
+
569
+ Args:
570
+ archetype_id: Observation archetype ID.
571
+ ehr_id: Optional EHR ID filter.
572
+ limit: Maximum results.
573
+
574
+ Returns:
575
+ AQLQuery for observations.
576
+ """
577
+ builder = (
578
+ AQLBuilder()
579
+ .select("o")
580
+ .from_ehr()
581
+ .contains_composition()
582
+ .contains_observation(archetype_id=archetype_id)
583
+ )
584
+
585
+ if ehr_id:
586
+ builder.where_ehr_id()
587
+ builder.param("ehr_id", ehr_id)
588
+
589
+ return builder.limit(limit).build()
@@ -0,0 +1,32 @@
1
+ """
2
+ REST client for EHRBase and openEHR CDR servers.
3
+
4
+ This module provides async HTTP clients for interacting with
5
+ openEHR Clinical Data Repositories.
6
+ """
7
+
8
+ from .ehrbase import (
9
+ AuthenticationError,
10
+ CompositionFormat,
11
+ CompositionResponse,
12
+ EHRBaseClient,
13
+ EHRBaseConfig,
14
+ EHRBaseError,
15
+ EHRResponse,
16
+ NotFoundError,
17
+ QueryResponse,
18
+ ValidationError,
19
+ )
20
+
21
+ __all__ = [
22
+ "EHRBaseClient",
23
+ "EHRBaseConfig",
24
+ "EHRResponse",
25
+ "CompositionResponse",
26
+ "CompositionFormat",
27
+ "QueryResponse",
28
+ "EHRBaseError",
29
+ "AuthenticationError",
30
+ "NotFoundError",
31
+ "ValidationError",
32
+ ]