singlestoredb 1.0.3__cp38-abi3-win32.whl → 1.1.0__cp38-abi3-win32.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 singlestoredb might be problematic. Click here for more details.

Files changed (31) hide show
  1. _singlestoredb_accel.pyd +0 -0
  2. singlestoredb/__init__.py +1 -1
  3. singlestoredb/config.py +125 -0
  4. singlestoredb/functions/dtypes.py +5 -198
  5. singlestoredb/functions/ext/__init__.py +0 -1
  6. singlestoredb/functions/ext/asgi.py +665 -153
  7. singlestoredb/functions/ext/json.py +2 -2
  8. singlestoredb/functions/ext/mmap.py +174 -67
  9. singlestoredb/functions/ext/rowdat_1.py +2 -2
  10. singlestoredb/functions/ext/utils.py +169 -0
  11. singlestoredb/fusion/handler.py +109 -9
  12. singlestoredb/fusion/handlers/stage.py +150 -0
  13. singlestoredb/fusion/handlers/workspace.py +265 -4
  14. singlestoredb/fusion/registry.py +69 -1
  15. singlestoredb/http/connection.py +40 -2
  16. singlestoredb/management/utils.py +30 -0
  17. singlestoredb/management/workspace.py +209 -35
  18. singlestoredb/mysql/connection.py +69 -0
  19. singlestoredb/mysql/cursors.py +176 -4
  20. singlestoredb/tests/test.sql +210 -0
  21. singlestoredb/tests/test_connection.py +1408 -0
  22. singlestoredb/tests/test_ext_func.py +2 -2
  23. singlestoredb/tests/test_ext_func_data.py +1 -1
  24. singlestoredb/utils/dtypes.py +205 -0
  25. singlestoredb/utils/results.py +367 -14
  26. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/METADATA +2 -1
  27. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/RECORD +31 -29
  28. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/LICENSE +0 -0
  29. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/WHEEL +0 -0
  30. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/entry_points.txt +0 -0
  31. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/top_level.txt +0 -0
@@ -17,6 +17,22 @@ class ShowRegionsHandler(SQLHandler):
17
17
  """
18
18
  SHOW REGIONS [ <like> ] [ <order-by> ] [ <limit> ];
19
19
 
20
+ Description
21
+ -----------
22
+ Show all available regions.
23
+
24
+ Remarks
25
+ -------
26
+ * ``LIKE`` specifies a pattern to match. ``%`` is a wildcard.
27
+ * ``ORDER BY`` specifies the column names to sort by.
28
+ * ``LIMIT`` indicates a maximum number of results to return.
29
+
30
+ Example
31
+ -------
32
+ Show all regions in the US::
33
+
34
+ SHOW REGIONS LIKE 'US%' ORDER BY Name;
35
+
20
36
  """
21
37
 
22
38
  def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]:
@@ -42,6 +58,28 @@ class ShowWorkspaceGroupsHandler(SQLHandler):
42
58
  """
43
59
  SHOW WORKSPACE GROUPS [ <like> ] [ <extended> ] [ <order-by> ] [ <limit> ];
44
60
 
61
+ Description
62
+ -----------
63
+ Show workspace group information.
64
+
65
+ Remarks
66
+ -------
67
+ * ``LIKE`` specifies a pattern to match. ``%`` is a wildcard.
68
+ * ``EXTENDED`` indicates that extra workspace group information should
69
+ be returned in the result set.
70
+ * ``ORDER BY`` specifies the column names to sort by.
71
+ * ``LIMIT`` indicates a maximum number of results to return.
72
+
73
+ Example
74
+ -------
75
+ Display workspace groups that match a pattern incuding extended information::
76
+
77
+ SHOW WORKSPACE GROUPS LIKE 'Marketing%' EXTENDED ORDER BY Name;
78
+
79
+ See Also
80
+ --------
81
+ * SHOW WORKSPACES
82
+
45
83
  """
46
84
 
47
85
  def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]:
@@ -92,6 +130,30 @@ class ShowWorkspacesHandler(SQLHandler):
92
130
  # Name of group
93
131
  group_name = '<group-name>'
94
132
 
133
+ Description
134
+ -----------
135
+ Show workspaces in a workspace group.
136
+
137
+ Remarks
138
+ -------
139
+ * ``IN GROUP`` specifies the workspace group to list workspaces for. If a
140
+ workspace group ID is specified, you should use ``IN GROUP ID``.
141
+ * ``LIKE`` specifies a pattern to match. ``%`` is a wildcard.
142
+ * ``EXTENDED`` indicates that extra workspace group information should
143
+ be returned in the result set.
144
+ * ``ORDER BY`` specifies the column names to sort by.
145
+ * ``LIMIT`` indicates a maximum number of results to return.
146
+
147
+ Example
148
+ -------
149
+ Display workspaces in a workspace group including extended information::
150
+
151
+ SHOW WORKSPACES IN GROUP 'My Group' EXTENDED ORDER BY Name;
152
+
153
+ See Also
154
+ --------
155
+ * SHOW WORKSPACE GROUPS
156
+
95
157
  """
96
158
 
97
159
  def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]:
@@ -136,6 +198,11 @@ class CreateWorkspaceGroupHandler(SQLHandler):
136
198
  [ with_password ]
137
199
  [ expires_at ]
138
200
  [ with_firewall_ranges ]
201
+ [ with_backup_bucket_kms_key_id ]
202
+ [ with_data_bucket_kms_key_id ]
203
+ [ with_smart_dr ]
204
+ [ allow_all_traffic ]
205
+ [ with_update_window ]
139
206
  ;
140
207
 
141
208
  # Only create workspace group if it doesn't exist already
@@ -159,6 +226,60 @@ class CreateWorkspaceGroupHandler(SQLHandler):
159
226
  # Incoming IP ranges
160
227
  with_firewall_ranges = WITH FIREWALL RANGES '<ip-range>',...
161
228
 
229
+ # Backup bucket key
230
+ with_backup_bucket_kms_key_id = WITH BACKUP BUCKET KMS KEY ID '<key-id>'
231
+
232
+ # Data bucket key
233
+ with_data_bucket_kms_key_id = WITH DATA BUCKET KMS KEY ID '<key-id>'
234
+
235
+ # Smart DR
236
+ with_smart_dr = WITH SMART DR
237
+
238
+ # Allow all incoming traffic
239
+ allow_all_traffic = ALLOW ALL TRAFFIC
240
+
241
+ # Update window
242
+ with_update_window = WITH UPDATE WINDOW '<day>:<hour>'
243
+
244
+ Description
245
+ -----------
246
+ Create a workspace group.
247
+
248
+ Remarks
249
+ -------
250
+ * ``IF NOT EXISTS`` indicates that the creation of the workspace group
251
+ will only be attempted if a workspace group with that name doesn't
252
+ already exist.
253
+ * ``IN REGION`` specifies the region to create the workspace group in.
254
+ If a region ID is used, ``IN REGION ID`` should be used.
255
+ * ``EXPIRES AT`` specifies an expiration date/time or interval.
256
+ * ``WITH FIREWALL RANGES`` indicates IP ranges to allow access to the
257
+ workspace group.
258
+ * ``WITH BACKUP BUCKET KMS KEY ID`` is the key ID associated with the
259
+ backup bucket.
260
+ * ``WITH DATA BUCKET KMS KEY ID`` is the key ID associated with the
261
+ data bucket.
262
+ * ``WITH SMART DR`` enables smart disaster recovery.
263
+ * ``ALLOW ALL TRAFFIC`` allows all incoming traffic.
264
+ * ``WITH UPDATE WINDOW`` specifies tha day (0-6) and hour (0-23) of the
265
+ update window.
266
+
267
+ Examples
268
+ --------
269
+ Example 1: Create workspace group in US East 2 (Ohio)::
270
+
271
+ CREATE WORKSPACE GROUP 'My Group' IN REGION 'US East 2 (Ohio)';
272
+
273
+ Example 2: Create workspace group with region ID and accessible from anywhere::
274
+
275
+ CREATE WORKSPACE GROUP 'My Group'
276
+ IN REGION ID '93b61160-0cae-4e11-a5de-977b8e2e3ee5'
277
+ WITH FIREWALL RANGES '0.0.0.0/0';
278
+
279
+ See Also
280
+ --------
281
+ * SHOW WORKSPACE GROUPS
282
+
162
283
  """
163
284
 
164
285
  def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]:
@@ -185,12 +306,22 @@ class CreateWorkspaceGroupHandler(SQLHandler):
185
306
  else:
186
307
  region_id = params['region_id']
187
308
 
309
+ with_update_window = None
310
+ if params['with_update_window']:
311
+ day, hour = params['with_update_window'].split(':', 1)
312
+ with_update_window = dict(day=int(day), hour=int(hour))
313
+
188
314
  manager.create_workspace_group(
189
315
  params['group_name'],
190
316
  region=region_id,
191
317
  admin_password=params['with_password'],
192
318
  expires_at=params['expires_at'],
193
319
  firewall_ranges=params['with_firewall_ranges'],
320
+ backup_bucket_kms_key_id=params['with_backup_bucket_kms_key_id'],
321
+ data_bucket_kms_key_id=params['with_data_bucket_kms_key_id'],
322
+ smart_dr=params['with_smart_dr'],
323
+ allow_all_traffic=params['allow_all_traffic'],
324
+ update_window=with_update_window,
194
325
  )
195
326
 
196
327
  return None
@@ -202,7 +333,8 @@ CreateWorkspaceGroupHandler.register(overwrite=True)
202
333
  class CreateWorkspaceHandler(SQLHandler):
203
334
  """
204
335
  CREATE WORKSPACE [ if_not_exists ] workspace_name [ in_group ]
205
- WITH SIZE size [ wait_on_active ];
336
+ WITH SIZE size [ auto_suspend ] [ enable_kai ]
337
+ [ with_cache_config ] [ wait_on_active ];
206
338
 
207
339
  # Create workspace in workspace group
208
340
  in_group = IN GROUP { group_id | group_name }
@@ -222,9 +354,48 @@ class CreateWorkspaceHandler(SQLHandler):
222
354
  # Runtime size
223
355
  size = '<size>'
224
356
 
357
+ # Auto-suspend
358
+ auto_suspend = AUTO SUSPEND suspend_after_seconds SECONDS suspend_type
359
+ suspend_after_seconds = AFTER <integer>
360
+ suspend_type = WITH TYPE { IDLE | SCHEDULED | DISABLED }
361
+
362
+ # Enable Kai
363
+ enable_kai = ENABLE KAI
364
+
365
+ # Cache config
366
+ with_cache_config = WITH CACHE CONFIG <integer>
367
+
225
368
  # Wait for workspace to be active before continuing
226
369
  wait_on_active = WAIT ON ACTIVE
227
370
 
371
+ Description
372
+ -----------
373
+ Create a workspace in a workspace group.
374
+
375
+ Remarks
376
+ -------
377
+ * ``IF NOT EXISTS`` indicates that the creation of the workspace
378
+ will only be attempted if a workspace with that name doesn't
379
+ already exist.
380
+ * ``IN GROUP`` indicates the workspace group to create the workspace
381
+ in. If an ID is used, ``IN GROUP ID`` should be used.
382
+ * ``SIZE`` indicates a cluster size specification such as 'S-00'.
383
+ * ``WITH CACHE CONFIG`` specifies the multiplier for the persistent cache
384
+ associated with the workspace. It must be 1, 2, or 4.
385
+ * ``WAIT ON ACTIVE`` indicates that execution should be paused until
386
+ the workspace has reached the ACTIVE state.
387
+
388
+ Example
389
+ -------
390
+ Create a workspace group and wait until it is active::
391
+
392
+ CREATE WORKSPACE 'my-workspace' IN GROUP 'My Group'
393
+ WITH SIZE 'S-00' WAIT ON ACTIVE;
394
+
395
+ See Also
396
+ --------
397
+ * CREATE WORKSPACE GROUP
398
+
228
399
  """
229
400
 
230
401
  def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]:
@@ -238,8 +409,19 @@ class CreateWorkspaceHandler(SQLHandler):
238
409
  except KeyError:
239
410
  pass
240
411
 
412
+ auto_suspend = None
413
+ if params['auto_suspend']:
414
+ auto_suspend = dict(
415
+ suspend_after_seconds=params['auto_suspend'][0]['suspend_after_seconds'],
416
+ suspend_type=params['auto_suspend'][-1]['suspend_type'].upper(),
417
+ )
418
+
241
419
  workspace_group.create_workspace(
242
- params['workspace_name'], size=params['size'],
420
+ params['workspace_name'],
421
+ size=params['size'],
422
+ auto_suspend=auto_suspend,
423
+ enable_kai=params['enable_kai'],
424
+ cache_config=params['with_cache_config'],
243
425
  wait_on_active=params['wait_on_active'],
244
426
  )
245
427
 
@@ -274,6 +456,21 @@ class SuspendWorkspaceHandler(SQLHandler):
274
456
  # Wait for workspace to be suspended before continuing
275
457
  wait_on_suspended = WAIT ON SUSPENDED
276
458
 
459
+ Description
460
+ -----------
461
+ Suspend a workspace.
462
+
463
+ Remarks
464
+ -------
465
+ * ``IN GROUP`` indicates the workspace group of the workspace.
466
+ If an ID is used, ``IN GROUP ID`` should be used.
467
+ * ``WAIT ON SUSPENDED`` indicates that execution should be paused until
468
+ the workspace has reached the SUSPENDED state.
469
+
470
+ See Also
471
+ --------
472
+ * RESUME WORKSPACE
473
+
277
474
  """
278
475
 
279
476
  def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]:
@@ -287,7 +484,8 @@ SuspendWorkspaceHandler.register(overwrite=True)
287
484
 
288
485
  class ResumeWorkspaceHandler(SQLHandler):
289
486
  """
290
- RESUME WORKSPACE workspace [ in_group ] [ wait_on_resumed ];
487
+ RESUME WORKSPACE workspace [ in_group ]
488
+ [ disable_auto_suspend ] [ wait_on_resumed ];
291
489
 
292
490
  # Workspace
293
491
  workspace = { workspace_id | workspace_name }
@@ -307,14 +505,31 @@ class ResumeWorkspaceHandler(SQLHandler):
307
505
  # Name of workspace group
308
506
  group_name = '<group-name>'
309
507
 
508
+ # Disable auto-suspend
509
+ disable_auto_suspend = DISABLE AUTO SUSPEND
510
+
310
511
  # Wait for workspace to be resumed before continuing
311
512
  wait_on_resumed = WAIT ON RESUMED
312
513
 
514
+ Description
515
+ -----------
516
+ Resume a workspace.
517
+
518
+ Remarks
519
+ -------
520
+ * ``IN GROUP`` indicates the workspace group of the workspace.
521
+ If an ID is used, ``IN GROUP ID`` should be used.
522
+ * ``WAIT ON RESUMED`` indicates that execution should be paused until
523
+ the workspace has reached the RESUMED state.
524
+
313
525
  """
314
526
 
315
527
  def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]:
316
528
  ws = get_workspace(params)
317
- ws.resume(wait_on_resumed=params['wait_on_resumed'])
529
+ ws.resume(
530
+ wait_on_resumed=params['wait_on_resumed'],
531
+ disable_auto_suspend=params['disable_auto_suspend'],
532
+ )
318
533
  return None
319
534
 
320
535
 
@@ -343,6 +558,29 @@ class DropWorkspaceGroupHandler(SQLHandler):
343
558
  # Should the workspace group be terminated even if it has workspaces?
344
559
  force = FORCE
345
560
 
561
+ Description
562
+ -----------
563
+ Drop a workspace group.
564
+
565
+ Remarks
566
+ -------
567
+ * ``IF EXISTS`` indicates that the dropping of the workspace group should
568
+ only be attempted if a workspace group with the given name exists.
569
+ * ``WAIT ON TERMINATED`` specifies that execution should be paused
570
+ until the workspace group reaches the TERMINATED state.
571
+ * ``FORCE`` specifies that the workspace group should be terminated
572
+ even if it contains workspaces.
573
+
574
+ Example
575
+ -------
576
+ Drop a workspace group and all workspaces within it::
577
+
578
+ DROP WORKSPACE GROUP 'My Group' FORCE;
579
+
580
+ See Also
581
+ --------
582
+ * DROP WORKSPACE
583
+
346
584
  """
347
585
 
348
586
  def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]:
@@ -393,6 +631,29 @@ class DropWorkspaceHandler(SQLHandler):
393
631
  # Wait for workspace to be terminated before continuing
394
632
  wait_on_terminated = WAIT ON TERMINATED
395
633
 
634
+ Description
635
+ -----------
636
+ Drop a workspace.
637
+
638
+ Remarks
639
+ -------
640
+ * ``IF EXISTS`` indicates that the dropping of the workspace should
641
+ only be attempted if a workspace with the given name exists.
642
+ * ``IN GROUP`` indicates the workspace group of the workspace.
643
+ If an ID is used, ``IN GROUP ID`` should be used.
644
+ * ``WAIT ON TERMINATED`` specifies that execution should be paused
645
+ until the workspace reaches the TERMINATED state.
646
+
647
+ Example
648
+ -------
649
+ Drop a workspace if it exists::
650
+
651
+ DROP WORKSPACE IF EXISTS 'my-workspace' IN GROUP 'My Group';
652
+
653
+ See Also
654
+ --------
655
+ * DROP WORKSPACE GROUP
656
+
396
657
  """
397
658
 
398
659
  def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]:
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  import re
3
+ import sys
3
4
  from typing import Any
4
5
  from typing import Dict
5
6
  from typing import List
@@ -120,6 +121,20 @@ class ShowFusionCommandsHandler(SQLHandler):
120
121
  # LIKE pattern
121
122
  like = LIKE '<pattern>'
122
123
 
124
+ Description
125
+ -----------
126
+ Display all Fusion SQL commands.
127
+
128
+ Remarks
129
+ -------
130
+ * ``LIKE`` indicates a pattern of commands to display. ``%`` is a wildcard.
131
+
132
+ Example
133
+ -------
134
+ Display all commands starting with 'SHOW'::
135
+
136
+ SHOW FUSION COMMANDS LIKE 'SHOW%';
137
+
123
138
  """
124
139
 
125
140
  def run(self, params: Dict[str, Any]) -> Optional[result.FusionSQLResult]:
@@ -128,7 +143,7 @@ class ShowFusionCommandsHandler(SQLHandler):
128
143
 
129
144
  data: List[Tuple[Any, ...]] = []
130
145
  for _, v in sorted(_handlers.items()):
131
- data.append((v.help.lstrip(),))
146
+ data.append((v.syntax.lstrip(),))
132
147
 
133
148
  res.set_rows(data)
134
149
 
@@ -148,6 +163,20 @@ class ShowFusionGrammarHandler(SQLHandler):
148
163
  # Query to show grammar for
149
164
  for_query = FOR '<query>'
150
165
 
166
+ Description
167
+ -----------
168
+ Show the full grammar of a Fusion SQL command for a given query.
169
+
170
+ Remarks
171
+ -------
172
+ * ``<query>`` is a string containing a Fusion SQL command.
173
+
174
+ Example
175
+ -------
176
+ Display the full grammar of the ``CREATE WORKSPACE`` command::
177
+
178
+ SHOW FUSION GRAMMAR FOR 'CREATE WORKSPACE';
179
+
151
180
  """
152
181
 
153
182
  def run(self, params: Dict[str, Any]) -> Optional[result.FusionSQLResult]:
@@ -162,3 +191,42 @@ class ShowFusionGrammarHandler(SQLHandler):
162
191
 
163
192
 
164
193
  ShowFusionGrammarHandler.register()
194
+
195
+
196
+ class ShowFusionHelpHandler(SQLHandler):
197
+ """
198
+ SHOW FUSION HELP for_command;
199
+
200
+ # Command to show help for
201
+ for_command = FOR '<command>'
202
+
203
+ Description
204
+ -----------
205
+ Show the documentation for a Fusion SQL command.
206
+
207
+ Example
208
+ -------
209
+ Display the help for the ``CREATE WORKSPACE`` command::
210
+
211
+ SHOW FUSION HELP FOR 'CREATE WORKSPACE';
212
+
213
+ """
214
+
215
+ def run(self, params: Dict[str, Any]) -> Optional[result.FusionSQLResult]:
216
+ handler = get_handler(params['for_command'])
217
+ if handler is not None:
218
+ try:
219
+ from IPython.display import display
220
+ from IPython.display import Markdown
221
+ display(Markdown(handler.help))
222
+ except Exception:
223
+ print(handler.help)
224
+ else:
225
+ print(
226
+ f'No handler found for command \'{params["for_command"]}\'',
227
+ file=sys.stderr,
228
+ )
229
+ return None
230
+
231
+
232
+ ShowFusionHelpHandler.register()
@@ -62,6 +62,7 @@ from ..utils.debug import log_query
62
62
  from ..utils.mogrify import mogrify
63
63
  from ..utils.results import Description
64
64
  from ..utils.results import format_results
65
+ from ..utils.results import get_schema
65
66
  from ..utils.results import Result
66
67
 
67
68
 
@@ -333,6 +334,7 @@ class Cursor(connection.Cursor):
333
334
  self._row_idx: int = -1
334
335
  self._result_idx: int = -1
335
336
  self._descriptions: List[List[Description]] = []
337
+ self._schemas: List[Dict[str, Any]] = []
336
338
  self.arraysize: int = get_option('results.arraysize')
337
339
  self.rowcount: int = 0
338
340
  self.lastrowid: Optional[int] = None
@@ -355,6 +357,14 @@ class Cursor(connection.Cursor):
355
357
  return self._descriptions[self._result_idx]
356
358
  return None
357
359
 
360
+ @property
361
+ def _schema(self) -> Optional[Any]:
362
+ if not self._schemas:
363
+ return None
364
+ if self._result_idx >= 0 and self._result_idx < len(self._schemas):
365
+ return self._schemas[self._result_idx]
366
+ return None
367
+
358
368
  def _post(self, path: str, *args: Any, **kwargs: Any) -> requests.Response:
359
369
  """
360
370
  Invoke a POST request on the HTTP connection.
@@ -460,6 +470,7 @@ class Cursor(connection.Cursor):
460
470
  self._results_type = results_type
461
471
 
462
472
  self._descriptions.append(list(mgmt_res.description))
473
+ self._schemas.append(get_schema(self._results_type, list(mgmt_res.description)))
463
474
  self._results.append(list(mgmt_res.rows))
464
475
  self.rowcount = len(self._results[-1])
465
476
 
@@ -487,6 +498,7 @@ class Cursor(connection.Cursor):
487
498
  is_callproc: bool = False,
488
499
  ) -> int:
489
500
  self._descriptions = []
501
+ self._schemas = []
490
502
  self._results = []
491
503
  self._pymy_results = []
492
504
  self._row_idx = -1
@@ -571,6 +583,20 @@ class Cursor(connection.Cursor):
571
583
  if isinstance(k, int):
572
584
  http_converters[k] = v
573
585
 
586
+ # Make JSON a string for Arrow
587
+ if 'arrow' in self._results_type:
588
+ def json_to_str(x: Any) -> Optional[str]:
589
+ if x is None:
590
+ return None
591
+ return json.dumps(x)
592
+ http_converters[245] = json_to_str
593
+
594
+ # Don't convert date/times in polars
595
+ elif 'polars' in self._results_type:
596
+ http_converters.pop(7, None)
597
+ http_converters.pop(10, None)
598
+ http_converters.pop(12, None)
599
+
574
600
  results = out['results']
575
601
 
576
602
  # Convert data to Python types
@@ -616,6 +642,7 @@ class Cursor(connection.Cursor):
616
642
  )
617
643
  pymy_res.append(PyMyField(col['name'], flags, charset))
618
644
  self._descriptions.append(description)
645
+ self._schemas.append(get_schema(self._results_type, description))
619
646
 
620
647
  rows = convert_rows(result.get('rows', []), convs)
621
648
 
@@ -659,6 +686,7 @@ class Cursor(connection.Cursor):
659
686
  rowcount = 0
660
687
  if args is not None and len(args) > 0:
661
688
  description = []
689
+ schema = {}
662
690
  # Detect dataframes
663
691
  if hasattr(args, 'itertuples'):
664
692
  argiter = args.itertuples(index=False) # type: ignore
@@ -668,11 +696,14 @@ class Cursor(connection.Cursor):
668
696
  self.execute(query, params)
669
697
  if self._descriptions:
670
698
  description = self._descriptions[-1]
699
+ if self._schemas:
700
+ schema = self._schemas[-1]
671
701
  if self._rows is not None:
672
702
  results.append(self._rows)
673
703
  rowcount += self.rowcount
674
704
  self._results = results
675
705
  self._descriptions = [description for _ in range(len(results))]
706
+ self._schemas = [schema for _ in range(len(results))]
676
707
  else:
677
708
  self.execute(query)
678
709
  rowcount += self.rowcount
@@ -721,6 +752,7 @@ class Cursor(connection.Cursor):
721
752
  self._results_type,
722
753
  self.description or [],
723
754
  out, single=True,
755
+ schema=self._schema,
724
756
  )
725
757
 
726
758
  def fetchmany(
@@ -752,7 +784,10 @@ class Cursor(connection.Cursor):
752
784
  size = max(int(size), 1)
753
785
  out = self._rows[self._row_idx:self._row_idx+size]
754
786
  self._row_idx += len(out)
755
- return format_results(self._results_type, self.description or [], out)
787
+ return format_results(
788
+ self._results_type, self.description or [],
789
+ out, schema=self._schema,
790
+ )
756
791
 
757
792
  def fetchall(self) -> Result:
758
793
  """
@@ -774,7 +809,10 @@ class Cursor(connection.Cursor):
774
809
  return tuple()
775
810
  out = list(self._rows[self._row_idx:])
776
811
  self._row_idx = len(out)
777
- return format_results(self._results_type, self.description or [], out)
812
+ return format_results(
813
+ self._results_type, self.description or [],
814
+ out, schema=self._schema,
815
+ )
778
816
 
779
817
  def nextset(self) -> Optional[bool]:
780
818
  """Skip to the next available result set."""
@@ -9,6 +9,7 @@ from typing import Any
9
9
  from typing import Callable
10
10
  from typing import Dict
11
11
  from typing import List
12
+ from typing import Mapping
12
13
  from typing import Optional
13
14
  from typing import SupportsIndex
14
15
  from typing import TypeVar
@@ -282,3 +283,32 @@ def camel_to_snake(s: Optional[str]) -> Optional[str]:
282
283
  if out and out[0] == '_':
283
284
  return out[1:]
284
285
  return out
286
+
287
+
288
+ def snake_to_camel_dict(
289
+ s: Optional[Mapping[str, Any]],
290
+ cap_first: bool = False,
291
+ ) -> Optional[Dict[str, Any]]:
292
+ """Convert snake-case keys to camel-case keys."""
293
+ if s is None:
294
+ return None
295
+ out = {}
296
+ for k, v in s.items():
297
+ if isinstance(s, Mapping):
298
+ out[str(snake_to_camel(k))] = snake_to_camel_dict(v, cap_first=cap_first)
299
+ else:
300
+ out[str(snake_to_camel(k))] = v
301
+ return out
302
+
303
+
304
+ def camel_to_snake_dict(s: Optional[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
305
+ """Convert camel-case keys to snake-case keys."""
306
+ if s is None:
307
+ return None
308
+ out = {}
309
+ for k, v in s.items():
310
+ if isinstance(s, Mapping):
311
+ out[str(camel_to_snake(k))] = camel_to_snake_dict(v)
312
+ else:
313
+ out[str(camel_to_snake(k))] = v
314
+ return out