ydb-sqlalchemy 0.1.9__tar.gz → 0.1.10__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 (37) hide show
  1. {ydb_sqlalchemy-0.1.9/ydb_sqlalchemy.egg-info → ydb_sqlalchemy-0.1.10}/PKG-INFO +88 -1
  2. ydb_sqlalchemy-0.1.10/README.md +171 -0
  3. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/setup.py +1 -1
  4. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/test/test_suite.py +255 -1
  5. ydb_sqlalchemy-0.1.10/ydb_sqlalchemy/_version.py +1 -0
  6. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/sqlalchemy/__init__.py +2 -0
  7. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/sqlalchemy/compiler/base.py +8 -2
  8. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/sqlalchemy/datetime_types.py +10 -0
  9. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/sqlalchemy/test_sqlalchemy.py +10 -0
  10. ydb_sqlalchemy-0.1.10/ydb_sqlalchemy/sqlalchemy/types.py +141 -0
  11. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10/ydb_sqlalchemy.egg-info}/PKG-INFO +88 -1
  12. ydb_sqlalchemy-0.1.9/README.md +0 -84
  13. ydb_sqlalchemy-0.1.9/ydb_sqlalchemy/_version.py +0 -1
  14. ydb_sqlalchemy-0.1.9/ydb_sqlalchemy/sqlalchemy/types.py +0 -80
  15. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/LICENSE +0 -0
  16. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/MANIFEST.in +0 -0
  17. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/pyproject.toml +0 -0
  18. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/requirements.txt +0 -0
  19. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/setup.cfg +0 -0
  20. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/test/__init__.py +0 -0
  21. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/test/conftest.py +0 -0
  22. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/test/test_core.py +0 -0
  23. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/test/test_inspect.py +0 -0
  24. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/test/test_orm.py +0 -0
  25. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/__init__.py +0 -0
  26. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/sqlalchemy/compiler/__init__.py +0 -0
  27. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/sqlalchemy/compiler/sa14.py +0 -0
  28. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/sqlalchemy/compiler/sa20.py +0 -0
  29. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/sqlalchemy/dbapi_adapter.py +0 -0
  30. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/sqlalchemy/dml.py +0 -0
  31. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/sqlalchemy/json.py +0 -0
  32. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy/sqlalchemy/requirements.py +0 -0
  33. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy.egg-info/SOURCES.txt +0 -0
  34. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy.egg-info/dependency_links.txt +0 -0
  35. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy.egg-info/entry_points.txt +0 -0
  36. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy.egg-info/requires.txt +0 -0
  37. {ydb_sqlalchemy-0.1.9 → ydb_sqlalchemy-0.1.10}/ydb_sqlalchemy.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ydb-sqlalchemy
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: YDB Dialect for SQLAlchemy
5
5
  Home-page: http://github.com/ydb-platform/ydb-sqlalchemy
6
6
  Author: Yandex LLC
@@ -62,6 +62,82 @@ with engine.connect() as conn:
62
62
 
63
63
  ```
64
64
 
65
+ ## Authentication
66
+
67
+ To specify credentials, you should pass `credentials` object to `connect_args` argument of `create_engine` method.
68
+
69
+ ### Static Credentials
70
+
71
+ To use static credentials you should specify `username` and `password` as follows:
72
+
73
+ ```python3
74
+ engine = sa.create_engine(
75
+ "yql+ydb://localhost:2136/local",
76
+ connect_args = {
77
+ "credentials": {
78
+ "username": "...",
79
+ "password": "..."
80
+ }
81
+ }
82
+ )
83
+ ```
84
+
85
+ ### Token Credentials
86
+
87
+ To use access token credentials you should specify `token` as follows:
88
+
89
+ ```python3
90
+ engine = sa.create_engine(
91
+ "yql+ydb://localhost:2136/local",
92
+ connect_args = {
93
+ "credentials": {
94
+ "token": "..."
95
+ }
96
+ }
97
+ )
98
+ ```
99
+
100
+ ### Service Account Credentials
101
+
102
+ To use service account credentials you should specify `service_account_json` as follows:
103
+
104
+ ```python3
105
+ engine = sa.create_engine(
106
+ "yql+ydb://localhost:2136/local",
107
+ connect_args = {
108
+ "credentials": {
109
+ "service_account_json": {
110
+ "id": "...",
111
+ "service_account_id": "...",
112
+ "created_at": "...",
113
+ "key_algorithm": "...",
114
+ "public_key": "...",
115
+ "private_key": "..."
116
+ }
117
+ }
118
+ }
119
+ )
120
+ ```
121
+
122
+ ### Credentials from YDB SDK
123
+
124
+ To use any credentials that comes with `ydb` package, just pass credentials object as follows:
125
+
126
+ ```python3
127
+ import ydb.iam
128
+
129
+ engine = sa.create_engine(
130
+ "yql+ydb://localhost:2136/local",
131
+ connect_args = {
132
+ "credentials": ydb.iam.MetadataUrlCredentials()
133
+ }
134
+ )
135
+
136
+ ```
137
+
138
+
139
+ ## Migrations
140
+
65
141
  To setup `alembic` to work with `YDB` please check [this example](https://github.com/ydb-platform/ydb-sqlalchemy/tree/main/examples/alembic).
66
142
 
67
143
  ## Development
@@ -102,3 +178,14 @@ $ source venv/bin/activate
102
178
  $ pip install -r requirements.txt
103
179
  $ python examples/example.py
104
180
  ```
181
+
182
+ ## Additional Notes
183
+
184
+ ### Pandas
185
+ It is possible to use YDB SA engine with `pandas` fuctions [to_sql()](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_sql.html) and [read_sql](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_sql.html). However, there are some limitations:
186
+
187
+ * `to_sql` method can not be used with column tables, since it is impossible to specify `NOT NULL` columns with current `to_sql` arguments. YDB requires column tables to have `NOT NULL` attribute on `PK` columns.
188
+
189
+ * `to_sql` is not fully optimized to load huge datasets. It is recommended to use `method="multi"` and avoid setting a very large `chunksize`.
190
+
191
+ * `read_sql` is not fully optimized to load huge datasets and could lead to significant memory consumptions.
@@ -0,0 +1,171 @@
1
+ # YDB Dialect for SQLAlchemy
2
+ ---
3
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/ydb-platform/ydb-sqlalchemy/blob/main/LICENSE)
4
+ [![PyPI version](https://badge.fury.io/py/ydb-sqlalchemy.svg)](https://badge.fury.io/py/ydb-sqlalchemy)
5
+ [![Functional tests](https://github.com/ydb-platform/ydb-sqlalchemy/actions/workflows/tests.yml/badge.svg)](https://github.com/ydb-platform/ydb-sqlalchemy/actions/workflows/tests.yml)
6
+ [![Style checks](https://github.com/ydb-platform/ydb-sqlalchemy/actions/workflows/style.yml/badge.svg)](https://github.com/ydb-platform/ydb-sqlalchemy/actions/workflows/style.yml)
7
+
8
+ This repository contains YQL dialect for SqlAlchemy 2.0.
9
+
10
+ **Note**: Dialect also works with SqlAlchemy 1.4, but it is not fully tested.
11
+
12
+
13
+ ## Installation
14
+
15
+ ### Via PyPI
16
+ To install ydb-sqlalchemy from PyPI:
17
+
18
+ ```bash
19
+ $ pip install ydb-sqlalchemy
20
+ ```
21
+
22
+ ### Installation from source code
23
+ To work with current ydb-sqlalchemy version clone this repo and run from source root:
24
+
25
+ ```bash
26
+ $ pip install -U .
27
+ ```
28
+
29
+ ## Getting started
30
+
31
+ Connect to local YDB using SqlAlchemy:
32
+
33
+ ```python3
34
+ import sqlalchemy as sa
35
+
36
+
37
+ engine = sa.create_engine("yql+ydb://localhost:2136/local")
38
+
39
+ with engine.connect() as conn:
40
+ rs = conn.execute(sa.text("SELECT 1 AS value"))
41
+ print(rs.fetchone())
42
+
43
+ ```
44
+
45
+ ## Authentication
46
+
47
+ To specify credentials, you should pass `credentials` object to `connect_args` argument of `create_engine` method.
48
+
49
+ ### Static Credentials
50
+
51
+ To use static credentials you should specify `username` and `password` as follows:
52
+
53
+ ```python3
54
+ engine = sa.create_engine(
55
+ "yql+ydb://localhost:2136/local",
56
+ connect_args = {
57
+ "credentials": {
58
+ "username": "...",
59
+ "password": "..."
60
+ }
61
+ }
62
+ )
63
+ ```
64
+
65
+ ### Token Credentials
66
+
67
+ To use access token credentials you should specify `token` as follows:
68
+
69
+ ```python3
70
+ engine = sa.create_engine(
71
+ "yql+ydb://localhost:2136/local",
72
+ connect_args = {
73
+ "credentials": {
74
+ "token": "..."
75
+ }
76
+ }
77
+ )
78
+ ```
79
+
80
+ ### Service Account Credentials
81
+
82
+ To use service account credentials you should specify `service_account_json` as follows:
83
+
84
+ ```python3
85
+ engine = sa.create_engine(
86
+ "yql+ydb://localhost:2136/local",
87
+ connect_args = {
88
+ "credentials": {
89
+ "service_account_json": {
90
+ "id": "...",
91
+ "service_account_id": "...",
92
+ "created_at": "...",
93
+ "key_algorithm": "...",
94
+ "public_key": "...",
95
+ "private_key": "..."
96
+ }
97
+ }
98
+ }
99
+ )
100
+ ```
101
+
102
+ ### Credentials from YDB SDK
103
+
104
+ To use any credentials that comes with `ydb` package, just pass credentials object as follows:
105
+
106
+ ```python3
107
+ import ydb.iam
108
+
109
+ engine = sa.create_engine(
110
+ "yql+ydb://localhost:2136/local",
111
+ connect_args = {
112
+ "credentials": ydb.iam.MetadataUrlCredentials()
113
+ }
114
+ )
115
+
116
+ ```
117
+
118
+
119
+ ## Migrations
120
+
121
+ To setup `alembic` to work with `YDB` please check [this example](https://github.com/ydb-platform/ydb-sqlalchemy/tree/main/examples/alembic).
122
+
123
+ ## Development
124
+
125
+ ### Run Tests:
126
+
127
+ Run the command from the root directory of the repository to start YDB in a local docker container.
128
+ ```bash
129
+ $ docker-compose up
130
+ ```
131
+
132
+ To run all tests execute the command from the root directory of the repository:
133
+ ```bash
134
+ $ tox -e test-all
135
+ ```
136
+
137
+ Run specific test:
138
+ ```bash
139
+ $ tox -e test -- test/test_core.py
140
+ ```
141
+
142
+ Check code style:
143
+ ```bash
144
+ $ tox -e style
145
+ ```
146
+
147
+ Reformat code:
148
+ ```bash
149
+ $ tox -e isort
150
+ $ tox -e black-format
151
+ ```
152
+
153
+ Run example (needs running local YDB):
154
+ ```bash
155
+ $ python -m pip install virtualenv
156
+ $ virtualenv venv
157
+ $ source venv/bin/activate
158
+ $ pip install -r requirements.txt
159
+ $ python examples/example.py
160
+ ```
161
+
162
+ ## Additional Notes
163
+
164
+ ### Pandas
165
+ It is possible to use YDB SA engine with `pandas` fuctions [to_sql()](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_sql.html) and [read_sql](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_sql.html). However, there are some limitations:
166
+
167
+ * `to_sql` method can not be used with column tables, since it is impossible to specify `NOT NULL` columns with current `to_sql` arguments. YDB requires column tables to have `NOT NULL` attribute on `PK` columns.
168
+
169
+ * `to_sql` is not fully optimized to load huge datasets. It is recommended to use `method="multi"` and avoid setting a very large `chunksize`.
170
+
171
+ * `read_sql` is not fully optimized to load huge datasets and could lead to significant memory consumptions.
@@ -13,7 +13,7 @@ with open("requirements.txt") as f:
13
13
 
14
14
  setuptools.setup(
15
15
  name="ydb-sqlalchemy",
16
- version="0.1.9", # AUTOVERSION
16
+ version="0.1.10", # AUTOVERSION
17
17
  description="YDB Dialect for SQLAlchemy",
18
18
  author="Yandex LLC",
19
19
  author_email="ydb@yandex-team.ru",
@@ -1,4 +1,5 @@
1
1
  import ctypes
2
+ import decimal
2
3
 
3
4
  import pytest
4
5
  import sqlalchemy as sa
@@ -266,7 +267,7 @@ class IntegerTest(_IntegerTest):
266
267
  pass
267
268
 
268
269
 
269
- @pytest.mark.skip("TODO: fix & skip those tests - add Double/Decimal support. see #12")
270
+ @pytest.mark.skip("Use YdbDecimalTest for Decimal type testing")
270
271
  class NumericTest(_NumericTest):
271
272
  # SqlAlchemy maybe eat Decimal and throw Double
272
273
  pass
@@ -596,3 +597,256 @@ class RowFetchTest(_RowFetchTest):
596
597
  @pytest.mark.skip("scalar subquery unsupported")
597
598
  def test_row_w_scalar_select(self, connection):
598
599
  pass
600
+
601
+
602
+ class DecimalTest(fixtures.TablesTest):
603
+ """Tests for YDB Decimal type using standard sa.DECIMAL"""
604
+
605
+ @classmethod
606
+ def define_tables(cls, metadata):
607
+ Table(
608
+ "decimal_test",
609
+ metadata,
610
+ Column("id", Integer, primary_key=True),
611
+ Column("decimal_default", sa.DECIMAL), # Default: precision=22, scale=9
612
+ Column("decimal_custom", sa.DECIMAL(precision=10, scale=2)),
613
+ Column("decimal_as_float", sa.DECIMAL(asdecimal=False)), # Should behave like Float
614
+ )
615
+
616
+ def test_decimal_basic_operations(self, connection):
617
+ """Test basic insert and select operations with Decimal"""
618
+
619
+ table = self.tables.decimal_test
620
+
621
+ test_values = [
622
+ decimal.Decimal("1"),
623
+ decimal.Decimal("2"),
624
+ decimal.Decimal("3"),
625
+ ]
626
+
627
+ # Insert test values
628
+ for i, val in enumerate(test_values):
629
+ connection.execute(table.insert().values(id=i + 1, decimal_default=val))
630
+
631
+ # Select and verify
632
+ results = connection.execute(select(table.c.decimal_default).order_by(table.c.id)).fetchall()
633
+
634
+ for i, (result,) in enumerate(results):
635
+ expected = test_values[i]
636
+ assert isinstance(result, decimal.Decimal)
637
+ assert result == expected
638
+
639
+ def test_decimal_with_precision_scale(self, connection):
640
+ """Test Decimal with specific precision and scale"""
641
+
642
+ table = self.tables.decimal_test
643
+
644
+ # Test value that fits precision(10, 2)
645
+ test_value = decimal.Decimal("12345678.99")
646
+
647
+ connection.execute(table.insert().values(id=100, decimal_custom=test_value))
648
+
649
+ result = connection.scalar(select(table.c.decimal_custom).where(table.c.id == 100))
650
+
651
+ assert isinstance(result, decimal.Decimal)
652
+ assert result == test_value
653
+
654
+ def test_decimal_literal_rendering(self, connection):
655
+ """Test literal rendering of Decimal values"""
656
+ from sqlalchemy import literal
657
+
658
+ table = self.tables.decimal_test
659
+
660
+ # Test literal in INSERT
661
+ test_value = decimal.Decimal("999.99")
662
+
663
+ connection.execute(table.insert().values(id=300, decimal_default=literal(test_value, sa.DECIMAL())))
664
+
665
+ result = connection.scalar(select(table.c.decimal_default).where(table.c.id == 300))
666
+
667
+ assert isinstance(result, decimal.Decimal)
668
+ assert result == test_value
669
+
670
+ def test_decimal_overflow(self, connection):
671
+ """Test behavior when precision is exceeded"""
672
+
673
+ table = self.tables.decimal_test
674
+
675
+ # Try to insert value that exceeds precision=10, scale=2
676
+ overflow_value = decimal.Decimal("99999.99999")
677
+
678
+ with pytest.raises(Exception): # Should raise some kind of database error
679
+ connection.execute(table.insert().values(id=500, decimal_custom=overflow_value))
680
+ connection.commit()
681
+
682
+ def test_decimal_asdecimal_false(self, connection):
683
+ """Test DECIMAL with asdecimal=False (should return float)"""
684
+
685
+ table = self.tables.decimal_test
686
+
687
+ test_value = decimal.Decimal("123.45")
688
+
689
+ connection.execute(table.insert().values(id=600, decimal_as_float=test_value))
690
+
691
+ result = connection.scalar(select(table.c.decimal_as_float).where(table.c.id == 600))
692
+
693
+ assert isinstance(result, float), f"Expected float, got {type(result)}"
694
+ assert abs(result - 123.45) < 0.01
695
+
696
+ def test_decimal_arithmetic(self, connection):
697
+ """Test arithmetic operations with Decimal columns"""
698
+
699
+ table = self.tables.decimal_test
700
+
701
+ val1 = decimal.Decimal("100.50")
702
+ val2 = decimal.Decimal("25.25")
703
+
704
+ connection.execute(table.insert().values(id=900, decimal_default=val1))
705
+ connection.execute(table.insert().values(id=901, decimal_default=val2))
706
+
707
+ # Test various arithmetic operations
708
+ addition_result = connection.scalar(
709
+ select(table.c.decimal_default + decimal.Decimal("10.00")).where(table.c.id == 900)
710
+ )
711
+
712
+ subtraction_result = connection.scalar(
713
+ select(table.c.decimal_default - decimal.Decimal("5.25")).where(table.c.id == 900)
714
+ )
715
+
716
+ multiplication_result = connection.scalar(
717
+ select(table.c.decimal_default * decimal.Decimal("2.0")).where(table.c.id == 901)
718
+ )
719
+
720
+ division_result = connection.scalar(
721
+ select(table.c.decimal_default / decimal.Decimal("2.0")).where(table.c.id == 901)
722
+ )
723
+
724
+ # Verify results
725
+ assert abs(addition_result - decimal.Decimal("110.50")) < decimal.Decimal("0.01")
726
+ assert abs(subtraction_result - decimal.Decimal("95.25")) < decimal.Decimal("0.01")
727
+ assert abs(multiplication_result - decimal.Decimal("50.50")) < decimal.Decimal("0.01")
728
+ assert abs(division_result - decimal.Decimal("12.625")) < decimal.Decimal("0.01")
729
+
730
+ def test_decimal_comparison_operations(self, connection):
731
+ """Test comparison operations with Decimal columns"""
732
+
733
+ table = self.tables.decimal_test
734
+
735
+ values = [
736
+ decimal.Decimal("10.50"),
737
+ decimal.Decimal("20.75"),
738
+ decimal.Decimal("15.25"),
739
+ ]
740
+
741
+ for i, val in enumerate(values):
742
+ connection.execute(table.insert().values(id=1000 + i, decimal_default=val))
743
+
744
+ # Test various comparisons
745
+ greater_than = connection.execute(
746
+ select(table.c.id).where(table.c.decimal_default > decimal.Decimal("15.00")).order_by(table.c.id)
747
+ ).fetchall()
748
+
749
+ less_than = connection.execute(
750
+ select(table.c.id).where(table.c.decimal_default < decimal.Decimal("15.00")).order_by(table.c.id)
751
+ ).fetchall()
752
+
753
+ equal_to = connection.execute(
754
+ select(table.c.id).where(table.c.decimal_default == decimal.Decimal("15.25"))
755
+ ).fetchall()
756
+
757
+ between_values = connection.execute(
758
+ select(table.c.id)
759
+ .where(table.c.decimal_default.between(decimal.Decimal("15.00"), decimal.Decimal("21.00")))
760
+ .order_by(table.c.id)
761
+ ).fetchall()
762
+
763
+ # Verify results
764
+ assert len(greater_than) == 2 # 20.75 and 15.25
765
+ assert len(less_than) == 1 # 10.50
766
+ assert len(equal_to) == 1 # 15.25
767
+ assert len(between_values) == 2 # 20.75 and 15.25
768
+
769
+ def test_decimal_null_handling(self, connection):
770
+ """Test NULL handling with Decimal columns"""
771
+
772
+ table = self.tables.decimal_test
773
+
774
+ # Insert NULL value
775
+ connection.execute(table.insert().values(id=1100, decimal_default=None))
776
+
777
+ # Insert non-NULL value for comparison
778
+ connection.execute(table.insert().values(id=1101, decimal_default=decimal.Decimal("42.42")))
779
+
780
+ # Test NULL retrieval
781
+ null_result = connection.scalar(select(table.c.decimal_default).where(table.c.id == 1100))
782
+
783
+ non_null_result = connection.scalar(select(table.c.decimal_default).where(table.c.id == 1101))
784
+
785
+ assert null_result is None
786
+ assert non_null_result == decimal.Decimal("42.42")
787
+
788
+ # Test IS NULL / IS NOT NULL
789
+ null_count = connection.scalar(select(func.count()).where(table.c.decimal_default.is_(None)))
790
+
791
+ not_null_count = connection.scalar(select(func.count()).where(table.c.decimal_default.isnot(None)))
792
+
793
+ # Should have at least 1 NULL and several non-NULL values from other tests
794
+ assert null_count >= 1
795
+ assert not_null_count >= 1
796
+
797
+ def test_decimal_input_type_conversion(self, connection):
798
+ """Test that bind_processor handles different input types correctly (float, string, int, Decimal)"""
799
+
800
+ table = self.tables.decimal_test
801
+
802
+ # Test different input types that should all be converted to Decimal
803
+ test_cases = [
804
+ (1400, 123.45, "float input"), # float
805
+ (1401, "456.78", "string input"), # string
806
+ (1402, decimal.Decimal("789.12"), "decimal input"), # already Decimal
807
+ (1403, 100, "int input"), # int
808
+ ]
809
+
810
+ for test_id, input_value, description in test_cases:
811
+ connection.execute(table.insert().values(id=test_id, decimal_default=input_value))
812
+
813
+ result = connection.scalar(select(table.c.decimal_default).where(table.c.id == test_id))
814
+
815
+ # All should be returned as Decimal
816
+ assert isinstance(result, decimal.Decimal), f"Failed for {description}: got {type(result)}"
817
+
818
+ # Verify the value is approximately correct
819
+ expected = decimal.Decimal(str(input_value))
820
+ error_str = f"Failed for {description}: expected {expected}, got {result}"
821
+ assert abs(result - expected) < decimal.Decimal("0.01"), error_str
822
+
823
+ def test_decimal_asdecimal_comparison(self, connection):
824
+ """Test comparison between asdecimal=True and asdecimal=False behavior"""
825
+
826
+ table = self.tables.decimal_test
827
+
828
+ test_value = decimal.Decimal("999.123")
829
+
830
+ # Insert same value into both columns
831
+ connection.execute(
832
+ table.insert().values(
833
+ id=1500,
834
+ decimal_default=test_value, # asdecimal=True (default)
835
+ decimal_as_float=test_value, # asdecimal=False
836
+ )
837
+ )
838
+
839
+ # Get results from both columns
840
+ result_as_decimal = connection.scalar(select(table.c.decimal_default).where(table.c.id == 1500))
841
+ result_as_float = connection.scalar(select(table.c.decimal_as_float).where(table.c.id == 1500))
842
+
843
+ # Check types are different
844
+ assert isinstance(result_as_decimal, decimal.Decimal), f"Expected Decimal, got {type(result_as_decimal)}"
845
+ assert isinstance(result_as_float, float), f"Expected float, got {type(result_as_float)}"
846
+
847
+ # Check values are approximately equal
848
+ assert abs(result_as_decimal - test_value) < decimal.Decimal("0.001")
849
+ assert abs(result_as_float - float(test_value)) < 0.001
850
+
851
+ # Check that converting between them gives same value
852
+ assert abs(float(result_as_decimal) - result_as_float) < 0.001
@@ -0,0 +1 @@
1
+ VERSION = "0.1.10"
@@ -136,9 +136,11 @@ class YqlDialect(StrCompileDialect):
136
136
  colspecs = {
137
137
  sa.types.JSON: types.YqlJSON,
138
138
  sa.types.JSON.JSONPathType: types.YqlJSON.YqlJSONPathType,
139
+ sa.types.Date: types.YqlDate,
139
140
  sa.types.DateTime: types.YqlTimestamp, # Because YDB's DateTime doesn't store microseconds
140
141
  sa.types.DATETIME: types.YqlDateTime,
141
142
  sa.types.TIMESTAMP: types.YqlTimestamp,
143
+ sa.types.DECIMAL: types.Decimal,
142
144
  }
143
145
 
144
146
  connection_characteristics = util.immutabledict(
@@ -113,9 +113,13 @@ class BaseYqlTypeCompiler(StrSQLTypeCompiler):
113
113
  return "Int64"
114
114
 
115
115
  def visit_NUMERIC(self, type_: sa.Numeric, **kw):
116
- """Only Decimal(22,9) is supported for table columns"""
117
116
  return f"Decimal({type_.precision}, {type_.scale})"
118
117
 
118
+ def visit_DECIMAL(self, type_: sa.DECIMAL, **kw):
119
+ precision = getattr(type_, "precision", None) or 22
120
+ scale = getattr(type_, "scale", None) or 9
121
+ return f"Decimal({precision}, {scale})"
122
+
119
123
  def visit_BINARY(self, type_: sa.BINARY, **kw):
120
124
  return "String"
121
125
 
@@ -204,7 +208,9 @@ class BaseYqlTypeCompiler(StrSQLTypeCompiler):
204
208
  elif isinstance(type_, sa.Boolean):
205
209
  ydb_type = ydb.PrimitiveType.Bool
206
210
  elif isinstance(type_, sa.Numeric):
207
- ydb_type = ydb.DecimalType(type_.precision, type_.scale)
211
+ precision = getattr(type_, "precision", None) or 22
212
+ scale = getattr(type_, "scale", None) or 9
213
+ ydb_type = ydb.DecimalType(precision, scale)
208
214
  elif isinstance(type_, (types.ListType, sa.ARRAY)):
209
215
  ydb_type = ydb.ListType(self.get_ydb_type(type_.item_type, is_optional=False))
210
216
  elif isinstance(type_, types.StructType):
@@ -4,6 +4,16 @@ from typing import Optional
4
4
  from sqlalchemy import types as sqltypes
5
5
 
6
6
 
7
+ class YqlDate(sqltypes.Date):
8
+ def literal_processor(self, dialect):
9
+ parent = super().literal_processor(dialect)
10
+
11
+ def process(value):
12
+ return f"Date({parent(value)})"
13
+
14
+ return process
15
+
16
+
7
17
  class YqlTimestamp(sqltypes.TIMESTAMP):
8
18
  def result_processor(self, dialect, coltype):
9
19
  def process(value: Optional[datetime.datetime]) -> Optional[datetime.datetime]:
@@ -1,3 +1,4 @@
1
+ from datetime import date
1
2
  import sqlalchemy as sa
2
3
 
3
4
  from . import YqlDialect, types
@@ -25,3 +26,12 @@ def test_casts():
25
26
  "CAST(1/2 AS UInt8)",
26
27
  "String::JoinFromList(ListMap(TOPFREQ(1/2, 5), ($x) -> { RETURN CAST($x AS UTF8) ;}), ', ')",
27
28
  ]
29
+
30
+
31
+ def test_ydb_types():
32
+ dialect = YqlDialect()
33
+
34
+ query = sa.literal(date(1996, 11, 19))
35
+ compiled = query.compile(dialect=dialect, compile_kwargs={"literal_binds": True})
36
+
37
+ assert str(compiled) == "Date('1996-11-19')"
@@ -0,0 +1,141 @@
1
+ import decimal
2
+ from typing import Any, Mapping, Type, Union
3
+
4
+ from sqlalchemy import __version__ as sa_version
5
+
6
+ if sa_version.startswith("2."):
7
+ from sqlalchemy import ColumnElement
8
+ else:
9
+ from sqlalchemy.sql.expression import ColumnElement
10
+
11
+ from sqlalchemy import ARRAY, exc, types
12
+ from sqlalchemy.sql import type_api
13
+
14
+ from .datetime_types import YqlDate, YqlDateTime, YqlTimestamp # noqa: F401
15
+ from .json import YqlJSON # noqa: F401
16
+
17
+
18
+ class UInt64(types.Integer):
19
+ __visit_name__ = "uint64"
20
+
21
+
22
+ class UInt32(types.Integer):
23
+ __visit_name__ = "uint32"
24
+
25
+
26
+ class UInt16(types.Integer):
27
+ __visit_name__ = "uint16"
28
+
29
+
30
+ class UInt8(types.Integer):
31
+ __visit_name__ = "uint8"
32
+
33
+
34
+ class Int64(types.Integer):
35
+ __visit_name__ = "int64"
36
+
37
+
38
+ class Int32(types.Integer):
39
+ __visit_name__ = "int32"
40
+
41
+
42
+ class Int16(types.Integer):
43
+ __visit_name__ = "int32"
44
+
45
+
46
+ class Int8(types.Integer):
47
+ __visit_name__ = "int8"
48
+
49
+
50
+ class Decimal(types.DECIMAL):
51
+ __visit_name__ = "DECIMAL"
52
+
53
+ def __init__(self, precision=None, scale=None, asdecimal=True):
54
+ # YDB supports Decimal(22,9) by default
55
+ if precision is None:
56
+ precision = 22
57
+ if scale is None:
58
+ scale = 9
59
+ super().__init__(precision=precision, scale=scale, asdecimal=asdecimal)
60
+
61
+ def bind_processor(self, dialect):
62
+ def process(value):
63
+ if value is None:
64
+ return None
65
+ # Convert float to Decimal if needed
66
+ if isinstance(value, float):
67
+ return decimal.Decimal(str(value))
68
+ elif isinstance(value, str):
69
+ return decimal.Decimal(value)
70
+ elif not isinstance(value, decimal.Decimal):
71
+ return decimal.Decimal(str(value))
72
+ return value
73
+
74
+ return process
75
+
76
+ def result_processor(self, dialect, coltype):
77
+ def process(value):
78
+ if value is None:
79
+ return None
80
+
81
+ # YDB always returns Decimal values as decimal.Decimal objects
82
+ # But if asdecimal=False, we should convert to float
83
+ if not self.asdecimal:
84
+ return float(value)
85
+
86
+ # For asdecimal=True (default), return as Decimal
87
+ if not isinstance(value, decimal.Decimal):
88
+ return decimal.Decimal(str(value))
89
+ return value
90
+
91
+ return process
92
+
93
+ def literal_processor(self, dialect):
94
+ def process(value):
95
+ # Convert float to Decimal if needed
96
+ if isinstance(value, float):
97
+ value = decimal.Decimal(str(value))
98
+ elif not isinstance(value, decimal.Decimal):
99
+ value = decimal.Decimal(str(value))
100
+
101
+ # Use default precision and scale if not specified
102
+ precision = self.precision if self.precision is not None else 22
103
+ scale = self.scale if self.scale is not None else 9
104
+
105
+ return f'Decimal("{str(value)}", {precision}, {scale})'
106
+
107
+ return process
108
+
109
+
110
+ class ListType(ARRAY):
111
+ __visit_name__ = "list_type"
112
+
113
+
114
+ class HashableDict(dict):
115
+ def __hash__(self):
116
+ return hash(tuple(self.items()))
117
+
118
+
119
+ class StructType(types.TypeEngine[Mapping[str, Any]]):
120
+ __visit_name__ = "struct_type"
121
+
122
+ def __init__(self, fields_types: Mapping[str, Union[Type[types.TypeEngine], Type[types.TypeDecorator]]]):
123
+ self.fields_types = HashableDict(dict(sorted(fields_types.items())))
124
+
125
+ @property
126
+ def python_type(self):
127
+ return dict
128
+
129
+ def compare_values(self, x, y):
130
+ return x == y
131
+
132
+
133
+ class Lambda(ColumnElement):
134
+ __visit_name__ = "lambda"
135
+
136
+ def __init__(self, func):
137
+ if not callable(func):
138
+ raise exc.ArgumentError("func must be callable")
139
+
140
+ self.type = type_api.NULLTYPE
141
+ self.func = func
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ydb-sqlalchemy
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: YDB Dialect for SQLAlchemy
5
5
  Home-page: http://github.com/ydb-platform/ydb-sqlalchemy
6
6
  Author: Yandex LLC
@@ -62,6 +62,82 @@ with engine.connect() as conn:
62
62
 
63
63
  ```
64
64
 
65
+ ## Authentication
66
+
67
+ To specify credentials, you should pass `credentials` object to `connect_args` argument of `create_engine` method.
68
+
69
+ ### Static Credentials
70
+
71
+ To use static credentials you should specify `username` and `password` as follows:
72
+
73
+ ```python3
74
+ engine = sa.create_engine(
75
+ "yql+ydb://localhost:2136/local",
76
+ connect_args = {
77
+ "credentials": {
78
+ "username": "...",
79
+ "password": "..."
80
+ }
81
+ }
82
+ )
83
+ ```
84
+
85
+ ### Token Credentials
86
+
87
+ To use access token credentials you should specify `token` as follows:
88
+
89
+ ```python3
90
+ engine = sa.create_engine(
91
+ "yql+ydb://localhost:2136/local",
92
+ connect_args = {
93
+ "credentials": {
94
+ "token": "..."
95
+ }
96
+ }
97
+ )
98
+ ```
99
+
100
+ ### Service Account Credentials
101
+
102
+ To use service account credentials you should specify `service_account_json` as follows:
103
+
104
+ ```python3
105
+ engine = sa.create_engine(
106
+ "yql+ydb://localhost:2136/local",
107
+ connect_args = {
108
+ "credentials": {
109
+ "service_account_json": {
110
+ "id": "...",
111
+ "service_account_id": "...",
112
+ "created_at": "...",
113
+ "key_algorithm": "...",
114
+ "public_key": "...",
115
+ "private_key": "..."
116
+ }
117
+ }
118
+ }
119
+ )
120
+ ```
121
+
122
+ ### Credentials from YDB SDK
123
+
124
+ To use any credentials that comes with `ydb` package, just pass credentials object as follows:
125
+
126
+ ```python3
127
+ import ydb.iam
128
+
129
+ engine = sa.create_engine(
130
+ "yql+ydb://localhost:2136/local",
131
+ connect_args = {
132
+ "credentials": ydb.iam.MetadataUrlCredentials()
133
+ }
134
+ )
135
+
136
+ ```
137
+
138
+
139
+ ## Migrations
140
+
65
141
  To setup `alembic` to work with `YDB` please check [this example](https://github.com/ydb-platform/ydb-sqlalchemy/tree/main/examples/alembic).
66
142
 
67
143
  ## Development
@@ -102,3 +178,14 @@ $ source venv/bin/activate
102
178
  $ pip install -r requirements.txt
103
179
  $ python examples/example.py
104
180
  ```
181
+
182
+ ## Additional Notes
183
+
184
+ ### Pandas
185
+ It is possible to use YDB SA engine with `pandas` fuctions [to_sql()](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_sql.html) and [read_sql](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_sql.html). However, there are some limitations:
186
+
187
+ * `to_sql` method can not be used with column tables, since it is impossible to specify `NOT NULL` columns with current `to_sql` arguments. YDB requires column tables to have `NOT NULL` attribute on `PK` columns.
188
+
189
+ * `to_sql` is not fully optimized to load huge datasets. It is recommended to use `method="multi"` and avoid setting a very large `chunksize`.
190
+
191
+ * `read_sql` is not fully optimized to load huge datasets and could lead to significant memory consumptions.
@@ -1,84 +0,0 @@
1
- # YDB Dialect for SQLAlchemy
2
- ---
3
- [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/ydb-platform/ydb-sqlalchemy/blob/main/LICENSE)
4
- [![PyPI version](https://badge.fury.io/py/ydb-sqlalchemy.svg)](https://badge.fury.io/py/ydb-sqlalchemy)
5
- [![Functional tests](https://github.com/ydb-platform/ydb-sqlalchemy/actions/workflows/tests.yml/badge.svg)](https://github.com/ydb-platform/ydb-sqlalchemy/actions/workflows/tests.yml)
6
- [![Style checks](https://github.com/ydb-platform/ydb-sqlalchemy/actions/workflows/style.yml/badge.svg)](https://github.com/ydb-platform/ydb-sqlalchemy/actions/workflows/style.yml)
7
-
8
- This repository contains YQL dialect for SqlAlchemy 2.0.
9
-
10
- **Note**: Dialect also works with SqlAlchemy 1.4, but it is not fully tested.
11
-
12
-
13
- ## Installation
14
-
15
- ### Via PyPI
16
- To install ydb-sqlalchemy from PyPI:
17
-
18
- ```bash
19
- $ pip install ydb-sqlalchemy
20
- ```
21
-
22
- ### Installation from source code
23
- To work with current ydb-sqlalchemy version clone this repo and run from source root:
24
-
25
- ```bash
26
- $ pip install -U .
27
- ```
28
-
29
- ## Getting started
30
-
31
- Connect to local YDB using SqlAlchemy:
32
-
33
- ```python3
34
- import sqlalchemy as sa
35
-
36
-
37
- engine = sa.create_engine("yql+ydb://localhost:2136/local")
38
-
39
- with engine.connect() as conn:
40
- rs = conn.execute(sa.text("SELECT 1 AS value"))
41
- print(rs.fetchone())
42
-
43
- ```
44
-
45
- To setup `alembic` to work with `YDB` please check [this example](https://github.com/ydb-platform/ydb-sqlalchemy/tree/main/examples/alembic).
46
-
47
- ## Development
48
-
49
- ### Run Tests:
50
-
51
- Run the command from the root directory of the repository to start YDB in a local docker container.
52
- ```bash
53
- $ docker-compose up
54
- ```
55
-
56
- To run all tests execute the command from the root directory of the repository:
57
- ```bash
58
- $ tox -e test-all
59
- ```
60
-
61
- Run specific test:
62
- ```bash
63
- $ tox -e test -- test/test_core.py
64
- ```
65
-
66
- Check code style:
67
- ```bash
68
- $ tox -e style
69
- ```
70
-
71
- Reformat code:
72
- ```bash
73
- $ tox -e isort
74
- $ tox -e black-format
75
- ```
76
-
77
- Run example (needs running local YDB):
78
- ```bash
79
- $ python -m pip install virtualenv
80
- $ virtualenv venv
81
- $ source venv/bin/activate
82
- $ pip install -r requirements.txt
83
- $ python examples/example.py
84
- ```
@@ -1 +0,0 @@
1
- VERSION = "0.1.9"
@@ -1,80 +0,0 @@
1
- from typing import Any, Mapping, Type, Union
2
-
3
- from sqlalchemy import __version__ as sa_version
4
-
5
- if sa_version.startswith("2."):
6
- from sqlalchemy import ColumnElement
7
- else:
8
- from sqlalchemy.sql.expression import ColumnElement
9
-
10
- from sqlalchemy import ARRAY, exc, types
11
- from sqlalchemy.sql import type_api
12
-
13
- from .datetime_types import YqlDateTime, YqlTimestamp # noqa: F401
14
- from .json import YqlJSON # noqa: F401
15
-
16
-
17
- class UInt64(types.Integer):
18
- __visit_name__ = "uint64"
19
-
20
-
21
- class UInt32(types.Integer):
22
- __visit_name__ = "uint32"
23
-
24
-
25
- class UInt16(types.Integer):
26
- __visit_name__ = "uint16"
27
-
28
-
29
- class UInt8(types.Integer):
30
- __visit_name__ = "uint8"
31
-
32
-
33
- class Int64(types.Integer):
34
- __visit_name__ = "int64"
35
-
36
-
37
- class Int32(types.Integer):
38
- __visit_name__ = "int32"
39
-
40
-
41
- class Int16(types.Integer):
42
- __visit_name__ = "int32"
43
-
44
-
45
- class Int8(types.Integer):
46
- __visit_name__ = "int8"
47
-
48
-
49
- class ListType(ARRAY):
50
- __visit_name__ = "list_type"
51
-
52
-
53
- class HashableDict(dict):
54
- def __hash__(self):
55
- return hash(tuple(self.items()))
56
-
57
-
58
- class StructType(types.TypeEngine[Mapping[str, Any]]):
59
- __visit_name__ = "struct_type"
60
-
61
- def __init__(self, fields_types: Mapping[str, Union[Type[types.TypeEngine], Type[types.TypeDecorator]]]):
62
- self.fields_types = HashableDict(dict(sorted(fields_types.items())))
63
-
64
- @property
65
- def python_type(self):
66
- return dict
67
-
68
- def compare_values(self, x, y):
69
- return x == y
70
-
71
-
72
- class Lambda(ColumnElement):
73
- __visit_name__ = "lambda"
74
-
75
- def __init__(self, func):
76
- if not callable(func):
77
- raise exc.ArgumentError("func must be callable")
78
-
79
- self.type = type_api.NULLTYPE
80
- self.func = func
File without changes