ibm-db-haystack 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (20) hide show
  1. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/CHANGELOG.md +15 -0
  2. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/PKG-INFO +5 -2
  3. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/README.md +3 -0
  4. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/document_stores/ibm_db/document_store.py +95 -36
  5. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/tests/test_document_store.py +136 -0
  6. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/.gitignore +0 -0
  7. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/LICENSE.txt +0 -0
  8. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/docker-compose.yml +0 -0
  9. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/pydoc/config_docusaurus.yml +0 -0
  10. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/pyproject.toml +0 -0
  11. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/components/retrievers/ibm_db/__init__.py +0 -0
  12. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/components/retrievers/ibm_db/embedding_retriever.py +0 -0
  13. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
  14. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/document_stores/ibm_db/__init__.py +0 -0
  15. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/document_stores/ibm_db/filters.py +0 -0
  16. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/document_stores/py.typed +0 -0
  17. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/tests/__init__.py +0 -0
  18. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/tests/conftest.py +0 -0
  19. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/tests/test_embedding_retriever.py +0 -0
  20. {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/tests/test_filters.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/ibm_db-v0.2.0] - 2026-07-27
4
+
5
+ ### 🚀 Features
6
+
7
+ - IBM Db2 - lazy connection + closing methods (#3664)
8
+
9
+ ### 🐛 Bug Fixes
10
+
11
+ - Fix new issues raised by ruff 0.16.0 (#3670)
12
+
13
+ ### 📚 Documentation
14
+
15
+ - Update DB2 to Db2 in README (#3575)
16
+
17
+
3
18
  ## [integrations/ibm_db-v0.1.1] - 2026-07-10
4
19
 
5
20
  ### 📚 Documentation
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ibm-db-haystack
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Haystack integration for IBM Db2
5
5
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/ibm_db#readme
6
6
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
@@ -28,10 +28,13 @@ Description-Content-Type: text/markdown
28
28
  [![PyPI - Version](https://img.shields.io/pypi/v/ibm-db-haystack.svg)](https://pypi.org/project/ibm-db-haystack)
29
29
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ibm-db-haystack.svg)](https://pypi.org/project/ibm-db-haystack)
30
30
 
31
+ - [Integration page](https://haystack.deepset.ai/integrations/ibm-db-document-store)
31
32
  - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/ibm_db/CHANGELOG.md)
32
33
 
33
34
  ---
34
35
 
36
+ ## Contributing
37
+
35
38
  Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
36
39
 
37
40
  To run integration tests locally, you need to have an IBM Db2 instance running.
@@ -3,10 +3,13 @@
3
3
  [![PyPI - Version](https://img.shields.io/pypi/v/ibm-db-haystack.svg)](https://pypi.org/project/ibm-db-haystack)
4
4
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ibm-db-haystack.svg)](https://pypi.org/project/ibm-db-haystack)
5
5
 
6
+ - [Integration page](https://haystack.deepset.ai/integrations/ibm-db-document-store)
6
7
  - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/ibm_db/CHANGELOG.md)
7
8
 
8
9
  ---
9
10
 
11
+ ## Contributing
12
+
10
13
  Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
11
14
 
12
15
  To run integration tests locally, you need to have an IBM Db2 instance running.
@@ -579,50 +579,109 @@ class IBMDb2DocumentStore:
579
579
 
580
580
  return updated_count
581
581
 
582
- def get_metadata_field_unique_values(self, field: str) -> list[Any]:
582
+ @staticmethod
583
+ def _normalize_metadata_field_name(field_name: str) -> str:
583
584
  """
584
- Get all unique values for a given metadata field.
585
+ Normalize a metadata field name and validate its characters.
585
586
 
586
- :param field: The metadata field name (can include 'meta.' prefix)
587
- :return: List of unique values for the field
587
+ Removes the 'meta.' prefix, if present, and validates the remaining characters to prevent
588
+ SQL/JSONPath injection when the name is embedded in a SQL string literal.
589
+
590
+ :param field_name: The field name to normalize.
591
+ :return: The normalized field name.
592
+ :raises ValueError: If the field name contains characters other than alphanumerics, underscores,
593
+ or hyphens.
588
594
  """
589
- # Strip 'meta.' prefix if present
590
- field_name = field.removeprefix("meta.")
595
+ field_name = field_name.removeprefix("meta.")
591
596
 
592
- # Extract values from the JSON metadata field
593
- # Db2 has issues with DISTINCT/GROUP BY on JSON_VALUE results (SQL0134N error)
594
- # So we fetch all values and deduplicate in Python
595
- # Use RETURNING VARCHAR to explicitly specify the return type
596
- sql = (
597
- f"SELECT JSON_VALUE(SYSTOOLS.BSON2JSON(meta), '$.{field_name}' RETURNING VARCHAR(1000)) "
598
- f"FROM {self.table_name} "
599
- f"WHERE JSON_VALUE(SYSTOOLS.BSON2JSON(meta), '$.{field_name}' RETURNING VARCHAR(1000)) IS NOT NULL"
597
+ if not all(c.isalnum() or c in ("_", "-") for c in field_name):
598
+ msg = (
599
+ f"Invalid metadata field name: '{field_name}'. Field names can only contain alphanumeric "
600
+ f"characters, underscores, and hyphens."
601
+ )
602
+ raise ValueError(msg)
603
+
604
+ return field_name
605
+
606
+ def get_metadata_field_unique_values(
607
+ self,
608
+ metadata_field: str,
609
+ search_term: str | None = None,
610
+ from_: int = 0,
611
+ size: int = 10,
612
+ filters: dict[str, Any] | None = None,
613
+ ) -> tuple[list[Any], int]:
614
+ """
615
+ Get unique values for a given metadata field, optionally filtered by a search term.
616
+
617
+ **Note**: values of different types are kept distinct even when they compare equal in Python
618
+ or share a textual form (e.g. the int `1`, the bool `True` and the str `"1"` are returned as
619
+ three separate values, and a whole-number float like `1.0` stays distinct from the int `1`).
620
+
621
+ :param metadata_field: The metadata field name (can include or omit the 'meta.' prefix).
622
+ :param search_term: Optional term to filter the returned values by, matching as a case-insensitive
623
+ substring of the metadata field's own value (not the document content). If None, all values
624
+ are considered.
625
+ :param from_: The offset for pagination (0-based).
626
+ :param size: The number of unique values to return.
627
+ :param filters: Optional filters to restrict the documents considered.
628
+ :return: A tuple containing (list of unique values in their original JSON type, total count of
629
+ unique values matching `search_term`).
630
+ """
631
+ field_name = self._normalize_metadata_field_name(metadata_field)
632
+
633
+ # Db2 raises SQL0134N ("a LOB column can't be used with DISTINCT/GROUP BY") when DISTINCT is
634
+ # applied directly to a JSON_QUERY(...) expression derived from the BLOB `meta` column. Wrapping
635
+ # the extraction in a derived table materializes it as a plain VARCHAR column first, so DISTINCT
636
+ # and ORDER BY in the outer query operate on that column instead of the LOB-derived expression.
637
+ # `filters` is applied inside this subquery (not the outer one) since it references the raw
638
+ # `meta` column, which only exists at this level — the outer query only sees the extracted `value`.
639
+ #
640
+ # JSON_VALUE dequotes strings and stringifies numbers/booleans, so a JSON string "1" and the
641
+ # number 1 both become the identical SQL text '1' - merging distinct values and, once re-parsed,
642
+ # corrupting a numeric-looking string into a number. JSON_QUERY preserves the scalar's literal
643
+ # JSON form instead (quotes for strings, `true`/`false` for booleans), so json.loads() below can
644
+ # recover the original type, and DISTINCT compares literal text instead of collapsing values.
645
+ params: list[Any] = []
646
+ filter_where = ""
647
+ if filters:
648
+ filter_expression = FilterTranslator().translate(filters, params)
649
+ filter_where = f" WHERE {filter_expression}"
650
+
651
+ value_subquery = (
652
+ f"SELECT JSON_QUERY(SYSTOOLS.BSON2JSON(meta), '$.{field_name}' RETURNING VARCHAR(1000)) AS value "
653
+ f"FROM {self.table_name}{filter_where}"
600
654
  )
601
- with self._transaction(f"Failed to get unique values for field '{field}'") as cur:
602
- cur.execute(sql)
655
+
656
+ search_clause = ""
657
+ if search_term is not None:
658
+ search_clause = " AND LOCATE(UPPER(?), UPPER(value)) > 0"
659
+ params.append(search_term)
660
+
661
+ count_sql = f"SELECT COUNT(DISTINCT value) FROM ({value_subquery}) AS t WHERE value IS NOT NULL{search_clause}"
662
+ select_sql = (
663
+ f"SELECT DISTINCT value FROM ({value_subquery}) AS t WHERE value IS NOT NULL{search_clause} "
664
+ f"ORDER BY value OFFSET ? ROWS FETCH FIRST ? ROWS ONLY"
665
+ )
666
+
667
+ with self._transaction(f"Failed to get unique values for field '{metadata_field}'") as cur:
668
+ cur.execute(count_sql, params)
669
+ count_row = cur.fetchone()
670
+ total_count = count_row[0] if count_row and count_row[0] is not None else 0
671
+
672
+ cur.execute(select_sql, [*params, from_, size])
603
673
  rows = cur.fetchall()
604
674
 
605
- # Parse and deduplicate the values
606
- seen = set()
607
- values = []
675
+ unique_values: list[Any] = []
608
676
  for row in rows:
609
- value = row[0]
610
- if value is not None:
611
- # Try to parse as JSON to get the actual type
612
- try:
613
- parsed_value = json.loads(value)
614
- # Use JSON string for deduplication to handle unhashable types
615
- value_key = json.dumps(parsed_value, sort_keys=True)
616
- if value_key not in seen:
617
- seen.add(value_key)
618
- values.append(parsed_value)
619
- except (json.JSONDecodeError, TypeError):
620
- # If it's not valid JSON, use the string value
621
- if value not in seen:
622
- seen.add(value)
623
- values.append(value)
624
-
625
- return values
677
+ if row[0] is None:
678
+ continue
679
+ try:
680
+ unique_values.append(json.loads(row[0]))
681
+ except (json.JSONDecodeError, TypeError):
682
+ unique_values.append(row[0])
683
+
684
+ return unique_values, total_count
626
685
 
627
686
  def get_metadata_field_min_max(self, field: str) -> dict[str, Any]:
628
687
  """
@@ -239,6 +239,62 @@ class TestDocumentStore(
239
239
  assert [doc.id for doc in results] == ["a", "c"]
240
240
  assert all(doc.score is not None for doc in results)
241
241
 
242
+ def test_get_metadata_field_unique_values_pagination(self, document_store: IBMDb2DocumentStore):
243
+ docs = [
244
+ Document(content=f"doc {i}", meta={"category": c}) for i, c in enumerate(["A", "B", "A", "C", "B", "A"])
245
+ ]
246
+ document_store.write_documents(docs)
247
+
248
+ page1, total1 = document_store.get_metadata_field_unique_values("category", from_=0, size=2)
249
+ page2, total2 = document_store.get_metadata_field_unique_values("category", from_=2, size=2)
250
+
251
+ assert len(page1) == 2
252
+ assert len(page2) == 1
253
+ assert total1 == total2 == 3
254
+ assert not set(page1).intersection(page2)
255
+ assert set(page1) | set(page2) == {"A", "B", "C"}
256
+
257
+ all_values, total = document_store.get_metadata_field_unique_values("category", from_=0, size=100)
258
+ assert set(all_values) == {"A", "B", "C"}
259
+ assert total == 3
260
+
261
+ beyond, total_beyond = document_store.get_metadata_field_unique_values("category", from_=10, size=10)
262
+ assert beyond == []
263
+ assert total_beyond == 3
264
+
265
+ def test_get_metadata_field_unique_values_invalid_field_name(self, document_store: IBMDb2DocumentStore):
266
+ with pytest.raises(ValueError, match="Invalid metadata field name"):
267
+ document_store.get_metadata_field_unique_values("field' OR '1'='1")
268
+
269
+ def test_get_metadata_field_unique_values_hyphenated_field_name(self, document_store: IBMDb2DocumentStore):
270
+ docs = [
271
+ Document(content="one", meta={"some-field": "A"}),
272
+ Document(content="two", meta={"some-field": "B"}),
273
+ Document(content="three", meta={"some-field": "A"}),
274
+ ]
275
+ document_store.write_documents(docs)
276
+
277
+ values, total_count = document_store.get_metadata_field_unique_values("some-field")
278
+ assert set(values) == {"A", "B"}
279
+ assert total_count == 2
280
+
281
+ def test_case_insensitive_and_special_characters_are_allowed(self, document_store: IBMDb2DocumentStore):
282
+ docs = [
283
+ Document(meta={"category": "100% complete"}),
284
+ Document(meta={"category": "plain"}),
285
+ Document(meta={"category": "under_score"}),
286
+ Document(meta={"category": "underXscore"}),
287
+ ]
288
+ document_store.write_documents(docs)
289
+
290
+ values, total = document_store.get_metadata_field_unique_values("category", search_term="%")
291
+ assert values == ["100% complete"]
292
+ assert total == 1
293
+
294
+ values, total = document_store.get_metadata_field_unique_values("category", search_term="_")
295
+ assert values == ["under_score"]
296
+ assert total == 1
297
+
242
298
 
243
299
  class TestIBMDb2DocumentStoreUtilMethods:
244
300
  """Unit tests for pure utility methods: _parse_embedding, _validate_embedding, _infer_field_type."""
@@ -300,6 +356,32 @@ class TestIBMDb2DocumentStoreUtilMethods:
300
356
  def test_infer_field_type(self, value, expected):
301
357
  assert IBMDb2DocumentStore._infer_field_type(value) == expected
302
358
 
359
+ # _normalize_metadata_field_name
360
+ def test_normalize_metadata_field_name_strips_meta_prefix(self):
361
+ assert IBMDb2DocumentStore._normalize_metadata_field_name("meta.category") == "category"
362
+
363
+ def test_normalize_metadata_field_name_without_prefix_unchanged(self):
364
+ assert IBMDb2DocumentStore._normalize_metadata_field_name("category") == "category"
365
+
366
+ def test_normalize_metadata_field_name_allows_underscore_and_hyphen(self):
367
+ assert IBMDb2DocumentStore._normalize_metadata_field_name("meta.some_field-name") == "some_field-name"
368
+
369
+ @pytest.mark.parametrize(
370
+ "bad_field",
371
+ [
372
+ "field@invalid",
373
+ "field with spaces",
374
+ "field;drop",
375
+ "meta.field!",
376
+ "field' OR '1'='1",
377
+ "../etc/passwd",
378
+ "field/* comment */",
379
+ ],
380
+ )
381
+ def test_normalize_metadata_field_name_rejects_invalid_chars(self, bad_field):
382
+ with pytest.raises(ValueError, match="Invalid metadata field name"):
383
+ IBMDb2DocumentStore._normalize_metadata_field_name(bad_field)
384
+
303
385
 
304
386
  @pytest.fixture
305
387
  def unit_store(monkeypatch) -> IBMDb2DocumentStore:
@@ -575,3 +657,57 @@ class TestIBMDb2DocumentStoreUnit:
575
657
 
576
658
  with pytest.raises(RuntimeError, match="connection reset"):
577
659
  store._embedding_retrieval([0.1, 0.2], top_k=1)
660
+
661
+ def test_get_metadata_field_unique_values_builds_paginated_sql(self, mocked_store):
662
+ store, _, cur = mocked_store
663
+ cur.fetchone.return_value = (2,)
664
+ # JSON_QUERY returns the literal JSON form of a string - quoted - unlike JSON_VALUE which
665
+ # dequotes it; json.loads() below relies on that to tell a string apart from a number.
666
+ cur.fetchall.return_value = [('"A"',), ('"B"',)]
667
+
668
+ values, total_count = store.get_metadata_field_unique_values("meta.category", from_=5, size=10)
669
+
670
+ assert values == ["A", "B"]
671
+ assert total_count == 2
672
+
673
+ count_sql, count_params = cur.execute.call_args_list[0].args
674
+ select_sql, select_params = cur.execute.call_args_list[1].args
675
+
676
+ assert "COUNT(DISTINCT value)" in count_sql
677
+ assert "$.category" in count_sql
678
+ assert count_params == []
679
+
680
+ assert "SELECT DISTINCT value" in select_sql
681
+ assert "ORDER BY value OFFSET ? ROWS FETCH FIRST ? ROWS ONLY" in select_sql
682
+ assert select_params == [5, 10]
683
+
684
+ def test_get_metadata_field_unique_values_applies_search_term(self, mocked_store):
685
+ store, _, cur = mocked_store
686
+ cur.fetchone.return_value = (1,)
687
+ cur.fetchall.return_value = [('"Python-based"',)]
688
+
689
+ store.get_metadata_field_unique_values("meta.category", search_term="python")
690
+
691
+ count_sql, count_params = cur.execute.call_args_list[0].args
692
+ select_sql, select_params = cur.execute.call_args_list[1].args
693
+
694
+ assert "LOCATE(UPPER(?), UPPER(value)) > 0" in count_sql
695
+ assert count_params == ["python"]
696
+ assert "LOCATE(UPPER(?), UPPER(value)) > 0" in select_sql
697
+ assert select_params == ["python", 0, 10]
698
+
699
+ def test_get_metadata_field_unique_values_rejects_invalid_field_name(self, mocked_store):
700
+ store, _, _ = mocked_store
701
+
702
+ with pytest.raises(ValueError, match="Invalid metadata field name"):
703
+ store.get_metadata_field_unique_values("field' OR '1'='1")
704
+
705
+ def test_get_metadata_field_unique_values_handles_no_rows(self, mocked_store):
706
+ store, _, cur = mocked_store
707
+ cur.fetchone.return_value = None
708
+ cur.fetchall.return_value = []
709
+
710
+ values, total_count = store.get_metadata_field_unique_values("meta.category")
711
+
712
+ assert values == []
713
+ assert total_count == 0