meerschaum.connectors.sql

Subpackage for SQLConnector subclass

 1#! /usr/bin/env python
 2# -*- coding: utf-8 -*-
 3# vim:fenc=utf-8
 4
 5"""
 6Subpackage for SQLConnector subclass
 7"""
 8
 9from meerschaum.connectors.sql._SQLConnector import SQLConnector
10
11__all__ = ('SQLConnector',)
class SQLConnector(meerschaum.connectors.instance._InstanceConnector.InstanceConnector):
 20class SQLConnector(InstanceConnector):
 21    """
 22    Connect to SQL databases via `sqlalchemy`.
 23    
 24    SQLConnectors may be used as Meerschaum instance connectors.
 25    Read more about connectors and instances at
 26    https://meerschaum.io/reference/connectors/
 27
 28    """
 29
 30    from ._create_engine import flavor_configs, create_engine
 31    from ._sql import (
 32        read,
 33        value,
 34        exec,
 35        execute,
 36        to_sql,
 37        exec_queries,
 38        get_connection,
 39        _cleanup_connections,
 40    )
 41    from meerschaum.utils.sql import test_connection
 42    from ._fetch import fetch, get_pipe_metadef
 43    from ._cli import cli, _cli_exit
 44    from ._compress import (
 45        get_pipe_size,
 46        compress_pipe,
 47        decompress_pipe,
 48        apply_compression_policy,
 49        _get_compress_settings,
 50        _is_hypertable,
 51        _get_columnstore_settings_query,
 52        _get_columnstore_policy_query,
 53        _get_columnstore_remove_policy_query,
 54        _get_columnstore_disable_query,
 55        _get_integer_now_func_queries,
 56        set_integer_now_func,
 57    )
 58    from ._maintenance import (
 59        vacuum_pipe,
 60        analyze_pipe,
 61        _run_in_autocommit,
 62        _get_vacuum_queries,
 63        _get_analyze_query,
 64    )
 65    from ._partition import (
 66        _should_partition,
 67        _get_partition_column,
 68        _get_partition_count,
 69        _get_chunk_count_timescaledb,
 70        get_partition_info,
 71        partition_pipe,
 72        _partition_bounds,
 73        _partition_literal,
 74        _partition_name,
 75        _get_partition_ranges_for_df,
 76        _get_initial_partition_bounds,
 77        _create_missing_partitions,
 78        _create_missing_partitions_pg,
 79        _create_missing_partitions_mysql,
 80        _get_mysql_max_partition_bound,
 81        _partition_function_name,
 82        _partition_scheme_name,
 83        _get_partition_boundary_values,
 84        _get_mssql_partition_creation_queries,
 85        _get_mssql_max_partition_boundary,
 86        _create_missing_partitions_mssql,
 87        _get_partition_cleanup_queries,
 88    )
 89    from ._pipes import (
 90        fetch_pipes_keys,
 91        create_indices,
 92        drop_indices,
 93        get_create_index_queries,
 94        get_drop_index_queries,
 95        get_add_columns_queries,
 96        get_alter_columns_queries,
 97        delete_pipe,
 98        get_pipe_data,
 99        get_pipe_docs,
100        get_pipe_data_query,
101        register_pipe,
102        edit_pipe,
103        get_pipe_id,
104        get_pipe_attributes,
105        sync_pipe,
106        sync_pipe_inplace,
107        get_sync_time,
108        pipe_exists,
109        get_pipe_rowcount,
110        drop_pipe,
111        clear_pipe,
112        deduplicate_pipe,
113        get_pipe_table,
114        get_pipe_columns_types,
115        get_to_sql_dtype,
116        get_pipe_schema,
117        create_pipe_table_from_df,
118        get_pipe_columns_indices,
119        get_temporary_target,
120        create_pipe_indices,
121        drop_pipe_indices,
122        get_pipe_index_names,
123        _init_geopackage_pipe,
124    )
125    from ._plugins import (
126        get_plugins_pipe,
127        register_plugin,
128        delete_plugin,
129        get_plugin_id,
130        get_plugin_version,
131        get_plugins,
132        get_plugin_user_id,
133        get_plugin_username,
134        get_plugin_attributes,
135    )
136    from ._users import (
137        get_users_pipe,
138        register_user,
139        get_user_id,
140        get_users,
141        edit_user,
142        delete_user,
143        get_user_password_hash,
144        get_user_type,
145        get_user_attributes,
146    )
147    from ._uri import from_uri, parse_uri
148    from ._instance import (
149        _log_temporary_tables_creation,
150        _drop_temporary_table,
151        _drop_temporary_tables,
152        _drop_old_temporary_tables,
153    )
154
155    def __init__(
156        self,
157        label: Optional[str] = None,
158        flavor: Optional[str] = None,
159        wait: bool = False,
160        connect: bool = False,
161        debug: bool = False,
162        **kw: Any
163    ):
164        """
165        Parameters
166        ----------
167        label: str, default 'main'
168            The identifying label for the connector.
169            E.g. for `sql:main`, 'main' is the label.
170            Defaults to 'main'.
171
172        flavor: Optional[str], default None
173            The database flavor, e.g.
174            `'sqlite'`, `'postgresql'`, `'cockroachdb'`, etc.
175            To see supported flavors, run the `bootstrap connectors` command.
176
177        wait: bool, default False
178            If `True`, block until a database connection has been made.
179            Defaults to `False`.
180
181        connect: bool, default False
182            If `True`, immediately attempt to connect the database and raise
183            a warning if the connection fails.
184            Defaults to `False`.
185
186        debug: bool, default False
187            Verbosity toggle.
188            Defaults to `False`.
189
190        kw: Any
191            All other arguments will be passed to the connector's attributes.
192            Therefore, a connector may be made without being registered,
193            as long enough parameters are supplied to the constructor.
194        """
195        if 'uri' in kw:
196            uri = kw['uri']
197            if uri.startswith('postgres') and not uri.startswith('postgresql'):
198                uri = uri.replace('postgres', 'postgresql', 1)
199            if uri.startswith('postgresql') and not uri.startswith('postgresql+'):
200                uri = uri.replace('postgresql://', 'postgresql+psycopg://', 1)
201            if uri.startswith('timescaledb://'):
202                uri = uri.replace('timescaledb://', 'postgresql+psycopg://', 1)
203                flavor = 'timescaledb'
204            if uri.startswith('timescaledb-ha://'):
205                uri = uri.replace('timescaledb-ha://', 'postgresql+psycopg://', 1)
206                flavor = 'timescaledb-ha'
207            if uri.startswith('postgis://'):
208                uri = uri.replace('postgis://', 'postgresql+psycopg://', 1)
209                flavor = 'postgis'
210            kw['uri'] = uri
211            from_uri_params = self.from_uri(kw['uri'], as_dict=True)
212            label = label or from_uri_params.get('label', None)
213            _ = from_uri_params.pop('label', None)
214
215            ### Sometimes the flavor may be provided with a URI.
216            kw.update(from_uri_params)
217            if flavor:
218                kw['flavor'] = flavor
219
220        ### set __dict__ in base class
221        super().__init__(
222            'sql',
223            label = label or self.__dict__.get('label', None),
224            **kw
225        )
226
227        if self.__dict__.get('flavor', None) in ('sqlite', 'geopackage'):
228            self._reset_attributes()
229            self._set_attributes(
230                'sql',
231                label = label,
232                inherit_default = False,
233                **kw
234            )
235            ### For backwards compatability reasons, set the path for sql:local if its missing.
236            if (
237                self.label == 'local'
238                and self.__dict__.get('database', None) in (None, '{SQLITE_DB_PATH}')
239            ):
240                import meerschaum.config.paths as paths
241                self.database = paths.SQLITE_DB_PATH.as_posix()
242
243        ### ensure flavor and label are set accordingly
244        if 'flavor' not in self.__dict__:
245            if flavor is None and 'uri' not in self.__dict__:
246                raise ValueError(
247                    f"    Missing flavor. Provide flavor as a key for '{self}'."
248                )
249            self.flavor = flavor or self.parse_uri(self.__dict__['uri']).get('flavor', None)
250
251        if self.flavor == 'postgres':
252            self.flavor = 'postgresql'
253
254        self._debug = debug
255        ### Store the PID and thread at initialization
256        ### so we can dispose of the Pool in child processes or threads.
257        import os
258        import threading
259        self._pid = os.getpid()
260        self._thread_ident = threading.current_thread().ident
261        self._sessions = {}
262        self._locks = {'_sessions': threading.RLock(), }
263
264        ### verify the flavor's requirements are met
265        if self.flavor not in self.flavor_configs:
266            error(f"Flavor '{self.flavor}' is not supported by Meerschaum SQLConnector")
267        if not self.__dict__.get('uri'):
268            self.verify_attributes(
269                self.flavor_configs[self.flavor].get('requirements', set()),
270                debug=debug,
271            )
272
273        if wait:
274            from meerschaum.connectors.poll import retry_connect
275            retry_connect(connector=self, debug=debug)
276
277        if connect:
278            if not self.test_connection(debug=debug):
279                warn(f"Failed to connect with connector '{self}'!", stack=False)
280
281    @property
282    def Session(self):
283        if '_Session' not in self.__dict__:
284            if self.engine is None:
285                return None
286
287            from meerschaum.utils.packages import attempt_import
288            sqlalchemy_orm = attempt_import('sqlalchemy.orm', lazy=False)
289            session_factory = sqlalchemy_orm.sessionmaker(self.engine)
290            self._Session = sqlalchemy_orm.scoped_session(session_factory)
291
292        return self._Session
293
294    @property
295    def engine(self):
296        """
297        Return the SQLAlchemy engine connected to the configured database.
298        """
299        import os
300        import threading
301        if '_engine' not in self.__dict__:
302            self._engine, self._engine_str = self.create_engine(include_uri=True)
303
304        same_process = os.getpid() == self._pid
305        same_thread = threading.current_thread().ident == self._thread_ident
306
307        ### handle child processes
308        if not same_process:
309            self._pid = os.getpid()
310            self._thread = threading.current_thread()
311            warn("Different PID detected. Disposing of connections...")
312            self._engine.dispose()
313
314        ### handle different threads
315        if not same_thread:
316            if self.flavor == 'duckdb':
317                warn("Different thread detected.")
318                self._engine.dispose()
319
320        return self._engine
321
322    @property
323    def DATABASE_URL(self) -> str:
324        """
325        Return the URI connection string (alias for `SQLConnector.URI`.
326        """
327        _ = self.engine
328        return str(self._engine_str)
329
330    @property
331    def URI(self) -> str:
332        """
333        Return the URI connection string.
334        """
335        _ = self.engine
336        return str(self._engine_str)
337
338    @property
339    def IS_THREAD_SAFE(self) -> str:
340        """
341        Return whether this connector may be multithreaded.
342        """
343        if self.flavor in ('duckdb', 'oracle'):
344            return False
345        if self.flavor in ('sqlite', 'geopackage'):
346            return ':memory:' not in self.URI
347        return True
348
349    @property
350    def metadata(self):
351        """
352        Return the metadata bound to this configured schema.
353        """
354        from meerschaum.utils.packages import attempt_import
355        sqlalchemy = attempt_import('sqlalchemy', lazy=False)
356        if '_metadata' not in self.__dict__:
357            self._metadata = sqlalchemy.MetaData(schema=self.schema)
358        return self._metadata
359
360    @property
361    def instance_schema(self):
362        """
363        Return the schema name for Meerschaum tables. 
364        """
365        return self.schema
366
367    @property
368    def internal_schema(self):
369        """
370        Return the schema name for internal tables. 
371        """
372        from meerschaum._internal.static import STATIC_CONFIG
373        from meerschaum.utils.sql import NO_SCHEMA_FLAVORS
374        schema_name = self.__dict__.get('internal_schema', None) or (
375            STATIC_CONFIG['sql']['internal_schema']
376            if self.flavor not in NO_SCHEMA_FLAVORS
377            else self.schema
378        )
379
380        if '_internal_schema' not in self.__dict__:
381            self._internal_schema = schema_name
382        return self._internal_schema
383
384    @property
385    def db(self) -> Optional[databases.Database]:
386        from meerschaum.utils.packages import attempt_import
387        databases = attempt_import('databases', lazy=False, install=True)
388        url = self.DATABASE_URL
389        if 'mysql' in url:
390            url = url.replace('+pymysql', '')
391        if '_db' not in self.__dict__:
392            try:
393                self._db = databases.Database(url)
394            except KeyError:
395                ### Likely encountered an unsupported flavor.
396                from meerschaum.utils.warnings import warn
397                self._db = None
398        return self._db
399
400    @property
401    def db_version(self) -> Union[str, None]:
402        """
403        Return the database version.
404        """
405        _db_version = self.__dict__.get('_db_version', None)
406        if _db_version is not None:
407            return _db_version
408
409        from meerschaum.utils.sql import get_db_version
410        self._db_version = get_db_version(self)
411        return self._db_version
412
413    @property
414    def schema(self) -> Union[str, None]:
415        """
416        Return the default schema to use.
417        A value of `None` will not prepend a schema.
418        """
419        if 'schema' in self.__dict__:
420            return self.__dict__['schema']
421
422        from meerschaum.utils.sql import NO_SCHEMA_FLAVORS
423        if self.flavor in NO_SCHEMA_FLAVORS:
424            self.__dict__['schema'] = None
425            return None
426
427        sqlalchemy = mrsm.attempt_import('sqlalchemy', lazy=False)
428        _schema = sqlalchemy.inspect(self.engine).default_schema_name
429        self.__dict__['schema'] = _schema
430        return _schema
431
432    def get_metadata_cache_path(self, kind: str = 'json') -> pathlib.Path:
433        """
434        Return the path to the file to which to write metadata cache.
435        """
436        import meerschaum.config.paths as paths
437        filename = (
438            f'{self.label}-metadata.pkl'
439            if kind == 'pkl'
440            else f'{self.label}.json'
441        )
442        return paths.SQL_CONN_CACHE_RESOURCES_PATH / filename
443
444    def __getstate__(self):
445        return self.__dict__
446
447    def __setstate__(self, d):
448        self.__dict__.update(d)
449
450    def __call__(self):
451        return self

Connect to SQL databases via sqlalchemy.

SQLConnectors may be used as Meerschaum instance connectors. Read more about connectors and instances at https://meerschaum.io/reference/connectors/

SQLConnector( label: Optional[str] = None, flavor: Optional[str] = None, wait: bool = False, connect: bool = False, debug: bool = False, **kw: Any)
155    def __init__(
156        self,
157        label: Optional[str] = None,
158        flavor: Optional[str] = None,
159        wait: bool = False,
160        connect: bool = False,
161        debug: bool = False,
162        **kw: Any
163    ):
164        """
165        Parameters
166        ----------
167        label: str, default 'main'
168            The identifying label for the connector.
169            E.g. for `sql:main`, 'main' is the label.
170            Defaults to 'main'.
171
172        flavor: Optional[str], default None
173            The database flavor, e.g.
174            `'sqlite'`, `'postgresql'`, `'cockroachdb'`, etc.
175            To see supported flavors, run the `bootstrap connectors` command.
176
177        wait: bool, default False
178            If `True`, block until a database connection has been made.
179            Defaults to `False`.
180
181        connect: bool, default False
182            If `True`, immediately attempt to connect the database and raise
183            a warning if the connection fails.
184            Defaults to `False`.
185
186        debug: bool, default False
187            Verbosity toggle.
188            Defaults to `False`.
189
190        kw: Any
191            All other arguments will be passed to the connector's attributes.
192            Therefore, a connector may be made without being registered,
193            as long enough parameters are supplied to the constructor.
194        """
195        if 'uri' in kw:
196            uri = kw['uri']
197            if uri.startswith('postgres') and not uri.startswith('postgresql'):
198                uri = uri.replace('postgres', 'postgresql', 1)
199            if uri.startswith('postgresql') and not uri.startswith('postgresql+'):
200                uri = uri.replace('postgresql://', 'postgresql+psycopg://', 1)
201            if uri.startswith('timescaledb://'):
202                uri = uri.replace('timescaledb://', 'postgresql+psycopg://', 1)
203                flavor = 'timescaledb'
204            if uri.startswith('timescaledb-ha://'):
205                uri = uri.replace('timescaledb-ha://', 'postgresql+psycopg://', 1)
206                flavor = 'timescaledb-ha'
207            if uri.startswith('postgis://'):
208                uri = uri.replace('postgis://', 'postgresql+psycopg://', 1)
209                flavor = 'postgis'
210            kw['uri'] = uri
211            from_uri_params = self.from_uri(kw['uri'], as_dict=True)
212            label = label or from_uri_params.get('label', None)
213            _ = from_uri_params.pop('label', None)
214
215            ### Sometimes the flavor may be provided with a URI.
216            kw.update(from_uri_params)
217            if flavor:
218                kw['flavor'] = flavor
219
220        ### set __dict__ in base class
221        super().__init__(
222            'sql',
223            label = label or self.__dict__.get('label', None),
224            **kw
225        )
226
227        if self.__dict__.get('flavor', None) in ('sqlite', 'geopackage'):
228            self._reset_attributes()
229            self._set_attributes(
230                'sql',
231                label = label,
232                inherit_default = False,
233                **kw
234            )
235            ### For backwards compatability reasons, set the path for sql:local if its missing.
236            if (
237                self.label == 'local'
238                and self.__dict__.get('database', None) in (None, '{SQLITE_DB_PATH}')
239            ):
240                import meerschaum.config.paths as paths
241                self.database = paths.SQLITE_DB_PATH.as_posix()
242
243        ### ensure flavor and label are set accordingly
244        if 'flavor' not in self.__dict__:
245            if flavor is None and 'uri' not in self.__dict__:
246                raise ValueError(
247                    f"    Missing flavor. Provide flavor as a key for '{self}'."
248                )
249            self.flavor = flavor or self.parse_uri(self.__dict__['uri']).get('flavor', None)
250
251        if self.flavor == 'postgres':
252            self.flavor = 'postgresql'
253
254        self._debug = debug
255        ### Store the PID and thread at initialization
256        ### so we can dispose of the Pool in child processes or threads.
257        import os
258        import threading
259        self._pid = os.getpid()
260        self._thread_ident = threading.current_thread().ident
261        self._sessions = {}
262        self._locks = {'_sessions': threading.RLock(), }
263
264        ### verify the flavor's requirements are met
265        if self.flavor not in self.flavor_configs:
266            error(f"Flavor '{self.flavor}' is not supported by Meerschaum SQLConnector")
267        if not self.__dict__.get('uri'):
268            self.verify_attributes(
269                self.flavor_configs[self.flavor].get('requirements', set()),
270                debug=debug,
271            )
272
273        if wait:
274            from meerschaum.connectors.poll import retry_connect
275            retry_connect(connector=self, debug=debug)
276
277        if connect:
278            if not self.test_connection(debug=debug):
279                warn(f"Failed to connect with connector '{self}'!", stack=False)
Parameters
  • label (str, default 'main'): The identifying label for the connector. E.g. for sql:main, 'main' is the label. Defaults to 'main'.
  • flavor (Optional[str], default None): The database flavor, e.g. 'sqlite', 'postgresql', 'cockroachdb', etc. To see supported flavors, run the bootstrap connectors command.
  • wait (bool, default False): If True, block until a database connection has been made. Defaults to False.
  • connect (bool, default False): If True, immediately attempt to connect the database and raise a warning if the connection fails. Defaults to False.
  • debug (bool, default False): Verbosity toggle. Defaults to False.
  • kw (Any): All other arguments will be passed to the connector's attributes. Therefore, a connector may be made without being registered, as long enough parameters are supplied to the constructor.
Session
281    @property
282    def Session(self):
283        if '_Session' not in self.__dict__:
284            if self.engine is None:
285                return None
286
287            from meerschaum.utils.packages import attempt_import
288            sqlalchemy_orm = attempt_import('sqlalchemy.orm', lazy=False)
289            session_factory = sqlalchemy_orm.sessionmaker(self.engine)
290            self._Session = sqlalchemy_orm.scoped_session(session_factory)
291
292        return self._Session
engine
294    @property
295    def engine(self):
296        """
297        Return the SQLAlchemy engine connected to the configured database.
298        """
299        import os
300        import threading
301        if '_engine' not in self.__dict__:
302            self._engine, self._engine_str = self.create_engine(include_uri=True)
303
304        same_process = os.getpid() == self._pid
305        same_thread = threading.current_thread().ident == self._thread_ident
306
307        ### handle child processes
308        if not same_process:
309            self._pid = os.getpid()
310            self._thread = threading.current_thread()
311            warn("Different PID detected. Disposing of connections...")
312            self._engine.dispose()
313
314        ### handle different threads
315        if not same_thread:
316            if self.flavor == 'duckdb':
317                warn("Different thread detected.")
318                self._engine.dispose()
319
320        return self._engine

Return the SQLAlchemy engine connected to the configured database.

DATABASE_URL: str
322    @property
323    def DATABASE_URL(self) -> str:
324        """
325        Return the URI connection string (alias for `SQLConnector.URI`.
326        """
327        _ = self.engine
328        return str(self._engine_str)

Return the URI connection string (alias for SQLConnector.URI.

URI: str
330    @property
331    def URI(self) -> str:
332        """
333        Return the URI connection string.
334        """
335        _ = self.engine
336        return str(self._engine_str)

Return the URI connection string.

IS_THREAD_SAFE: str
338    @property
339    def IS_THREAD_SAFE(self) -> str:
340        """
341        Return whether this connector may be multithreaded.
342        """
343        if self.flavor in ('duckdb', 'oracle'):
344            return False
345        if self.flavor in ('sqlite', 'geopackage'):
346            return ':memory:' not in self.URI
347        return True

Return whether this connector may be multithreaded.

metadata
349    @property
350    def metadata(self):
351        """
352        Return the metadata bound to this configured schema.
353        """
354        from meerschaum.utils.packages import attempt_import
355        sqlalchemy = attempt_import('sqlalchemy', lazy=False)
356        if '_metadata' not in self.__dict__:
357            self._metadata = sqlalchemy.MetaData(schema=self.schema)
358        return self._metadata

Return the metadata bound to this configured schema.

instance_schema
360    @property
361    def instance_schema(self):
362        """
363        Return the schema name for Meerschaum tables. 
364        """
365        return self.schema

Return the schema name for Meerschaum tables.

internal_schema
367    @property
368    def internal_schema(self):
369        """
370        Return the schema name for internal tables. 
371        """
372        from meerschaum._internal.static import STATIC_CONFIG
373        from meerschaum.utils.sql import NO_SCHEMA_FLAVORS
374        schema_name = self.__dict__.get('internal_schema', None) or (
375            STATIC_CONFIG['sql']['internal_schema']
376            if self.flavor not in NO_SCHEMA_FLAVORS
377            else self.schema
378        )
379
380        if '_internal_schema' not in self.__dict__:
381            self._internal_schema = schema_name
382        return self._internal_schema

Return the schema name for internal tables.

db: Optional[databases.core.Database]
384    @property
385    def db(self) -> Optional[databases.Database]:
386        from meerschaum.utils.packages import attempt_import
387        databases = attempt_import('databases', lazy=False, install=True)
388        url = self.DATABASE_URL
389        if 'mysql' in url:
390            url = url.replace('+pymysql', '')
391        if '_db' not in self.__dict__:
392            try:
393                self._db = databases.Database(url)
394            except KeyError:
395                ### Likely encountered an unsupported flavor.
396                from meerschaum.utils.warnings import warn
397                self._db = None
398        return self._db
db_version: Optional[str]
400    @property
401    def db_version(self) -> Union[str, None]:
402        """
403        Return the database version.
404        """
405        _db_version = self.__dict__.get('_db_version', None)
406        if _db_version is not None:
407            return _db_version
408
409        from meerschaum.utils.sql import get_db_version
410        self._db_version = get_db_version(self)
411        return self._db_version

Return the database version.

schema: Optional[str]
413    @property
414    def schema(self) -> Union[str, None]:
415        """
416        Return the default schema to use.
417        A value of `None` will not prepend a schema.
418        """
419        if 'schema' in self.__dict__:
420            return self.__dict__['schema']
421
422        from meerschaum.utils.sql import NO_SCHEMA_FLAVORS
423        if self.flavor in NO_SCHEMA_FLAVORS:
424            self.__dict__['schema'] = None
425            return None
426
427        sqlalchemy = mrsm.attempt_import('sqlalchemy', lazy=False)
428        _schema = sqlalchemy.inspect(self.engine).default_schema_name
429        self.__dict__['schema'] = _schema
430        return _schema

Return the default schema to use. A value of None will not prepend a schema.

def get_metadata_cache_path(self, kind: str = 'json') -> pathlib.Path:
432    def get_metadata_cache_path(self, kind: str = 'json') -> pathlib.Path:
433        """
434        Return the path to the file to which to write metadata cache.
435        """
436        import meerschaum.config.paths as paths
437        filename = (
438            f'{self.label}-metadata.pkl'
439            if kind == 'pkl'
440            else f'{self.label}.json'
441        )
442        return paths.SQL_CONN_CACHE_RESOURCES_PATH / filename

Return the path to the file to which to write metadata cache.

flavor_configs = {'timescaledb': {'engine': 'postgresql+psycopg', 'create_engine': {'pool_size': 6, 'max_overflow': 6, 'pool_recycle': 3600, 'connect_args': {}}, 'omit_create_engine': {'method'}, 'to_sql': {}, 'requirements': {'host', 'database', 'password', 'username'}, 'defaults': {'port': 5432}}, 'timescaledb-ha': {'engine': 'postgresql+psycopg', 'create_engine': {'pool_size': 6, 'max_overflow': 6, 'pool_recycle': 3600, 'connect_args': {}}, 'omit_create_engine': {'method'}, 'to_sql': {}, 'requirements': {'host', 'database', 'password', 'username'}, 'defaults': {'port': 5432}}, 'postgresql': {'engine': 'postgresql+psycopg', 'create_engine': {'pool_size': 6, 'max_overflow': 6, 'pool_recycle': 3600, 'connect_args': {}}, 'omit_create_engine': {'method'}, 'to_sql': {}, 'requirements': {'host', 'database', 'password', 'username'}, 'defaults': {'port': 5432}}, 'postgis': {'engine': 'postgresql+psycopg', 'create_engine': {'pool_size': 6, 'max_overflow': 6, 'pool_recycle': 3600, 'connect_args': {}}, 'omit_create_engine': {'method'}, 'to_sql': {}, 'requirements': {'host', 'database', 'password', 'username'}, 'defaults': {'port': 5432}}, 'citus': {'engine': 'postgresql+psycopg', 'create_engine': {'pool_size': 6, 'max_overflow': 6, 'pool_recycle': 3600, 'connect_args': {}}, 'omit_create_engine': {'method'}, 'to_sql': {}, 'requirements': {'host', 'database', 'password', 'username'}, 'defaults': {'port': 5432}}, 'mssql': {'engine': 'mssql+pyodbc', 'create_engine': {'fast_executemany': True, 'use_insertmanyvalues': False, 'isolation_level': 'AUTOCOMMIT', 'use_setinputsizes': False, 'pool_pre_ping': True, 'ignore_no_transaction_on_rollback': True}, 'omit_create_engine': {'method'}, 'to_sql': {'method': None}, 'requirements': {'host', 'database', 'password', 'username'}, 'defaults': {'port': 1433, 'options': 'driver=ODBC Driver 18 for SQL Server&UseFMTONLY=Yes&TrustServerCertificate=yes&Encrypt=no&MARS_Connection=yes'}}, 'mysql': {'engine': 'mysql+pymysql', 'create_engine': {'pool_size': 6, 'max_overflow': 6, 'pool_recycle': 3600, 'connect_args': {}}, 'omit_create_engine': {'method'}, 'to_sql': {'method': 'multi'}, 'requirements': {'host', 'database', 'password', 'username'}, 'defaults': {'port': 3306}}, 'mariadb': {'engine': 'mysql+pymysql', 'create_engine': {'pool_size': 6, 'max_overflow': 6, 'pool_recycle': 3600, 'connect_args': {}}, 'omit_create_engine': {'method'}, 'to_sql': {'method': 'multi'}, 'requirements': {'host', 'database', 'password', 'username'}, 'defaults': {'port': 3306}}, 'oracle': {'engine': 'oracle+oracledb', 'create_engine': {'pool_size': 6, 'max_overflow': 6, 'pool_recycle': 3600, 'connect_args': {}}, 'omit_create_engine': {'method'}, 'to_sql': {'method': None}, 'requirements': {'host', 'database', 'password', 'username'}, 'defaults': {'port': 1521}}, 'sqlite': {'engine': 'sqlite', 'create_engine': {'pool_size': 6, 'max_overflow': 6, 'pool_recycle': 3600, 'connect_args': {}}, 'omit_create_engine': {'method'}, 'to_sql': {'method': 'multi'}, 'requirements': {'database'}, 'defaults': {}}, 'geopackage': {'engine': 'sqlite', 'create_engine': {'pool_size': 6, 'max_overflow': 6, 'pool_recycle': 3600, 'connect_args': {}}, 'omit_create_engine': {'method'}, 'to_sql': {'method': 'multi'}, 'requirements': {'database'}, 'defaults': {}}, 'duckdb': {'engine': 'duckdb', 'create_engine': {}, 'omit_create_engine': {'ALL'}, 'to_sql': {'method': 'multi'}, 'requirements': '', 'defaults': {}}, 'cockroachdb': {'engine': 'cockroachdb', 'omit_create_engine': {'method'}, 'create_engine': {'pool_size': 6, 'max_overflow': 6, 'pool_recycle': 3600, 'connect_args': {}}, 'to_sql': {'method': 'multi'}, 'requirements': {'host'}, 'defaults': {'port': 26257, 'database': 'defaultdb', 'username': 'root', 'password': 'admin'}}}
def create_engine( self, include_uri: bool = False, debug: bool = False, **kw) -> sqlalchemy.engine.base.Engine:
 45def create_engine(
 46    self,
 47    include_uri: bool = False,
 48    debug: bool = False,
 49    **kw
 50) -> 'sqlalchemy.engine.Engine':
 51    """Create a sqlalchemy engine by building the engine string."""
 52    from meerschaum.utils.packages import attempt_import
 53    from meerschaum.utils.warnings import error, warn
 54    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
 55    import urllib
 56    import copy
 57    ### Install and patch required drivers.
 58    if self.flavor in install_flavor_drivers:
 59        _ = attempt_import(
 60            *install_flavor_drivers[self.flavor],
 61            debug=debug,
 62            lazy=False,
 63            warn=False,
 64        )
 65        if self.flavor == 'mssql':
 66            _init_mssql_sqlalchemy()
 67
 68    ### supplement missing values with defaults (e.g. port number)
 69    for a, value in flavor_configs[self.flavor]['defaults'].items():
 70        if a not in self.__dict__:
 71            self.__dict__[a] = value
 72
 73    ### Verify that everything is in order.
 74    if self.flavor not in flavor_configs:
 75        error(f"Cannot create a connector with the flavor '{self.flavor}'.")
 76
 77    _engine = flavor_configs[self.flavor].get('engine', None)
 78    _username = self.__dict__.get('username', None)
 79    _password = self.__dict__.get('password', None)
 80    _host = self.__dict__.get('host', None)
 81    _port = self.__dict__.get('port', None)
 82    _database = self.__dict__.get('database', None)
 83    if _database == '{SQLITE_DB_PATH}':
 84        import meerschaum.config.paths as paths
 85        _database = paths.SQLITE_DB_PATH.as_posix()
 86    _options = self.__dict__.get('options', {})
 87    if isinstance(_options, str):
 88        _options = dict(urllib.parse.parse_qsl(_options))
 89    _uri = self.__dict__.get('uri', None)
 90
 91    ### Handle registering specific dialects (due to installing in virtual environments).
 92    if self.flavor in flavor_dialects:
 93        sqlalchemy.dialects.registry.register(*flavor_dialects[self.flavor])
 94
 95    ### self._sys_config was deepcopied and can be updated safely
 96    if self.flavor in ("sqlite", "duckdb", "geopackage"):
 97        engine_str = f"{_engine}:///{_database}" if not _uri else _uri
 98        if 'create_engine' not in self._sys_config:
 99            self._sys_config['create_engine'] = {}
100        if 'connect_args' not in self._sys_config['create_engine']:
101            self._sys_config['create_engine']['connect_args'] = {}
102        self._sys_config['create_engine']['connect_args'].update({"check_same_thread": False})
103    else:
104        engine_str = (
105            _engine + "://" + (_username if _username is not None else '') +
106            ((":" + urllib.parse.quote_plus(_password)) if _password is not None else '') +
107            "@" + _host + ((":" + str(_port)) if _port is not None else '') +
108            (("/" + _database) if _database is not None else '')
109            + (("?" + urllib.parse.urlencode(_options)) if _options else '')
110        ) if not _uri else _uri
111
112        ### Sometimes the timescaledb:// flavor can slip in.
113        if _uri and self.flavor in _uri:
114            if self.flavor in ('timescaledb', 'timescaledb-ha', 'postgis'):
115                engine_str = engine_str.replace(self.flavor, 'postgresql', 1)
116            elif _uri.startswith('postgresql://'):
117                engine_str = engine_str.replace('postgresql://', 'postgresql+psycopg2://')
118
119    if debug:
120        dprint(
121            (
122                (engine_str.replace(':' + _password, ':' + ('*' * len(_password))))
123                    if _password is not None else engine_str
124            ) + '\n' + f"{self._sys_config.get('create_engine', {}).get('connect_args', {})}"
125        )
126
127    _kw_copy = copy.deepcopy(kw)
128
129    ### NOTE: Order of inheritance:
130    ###       1. Defaults
131    ###       2. System configuration
132    ###       3. Connector configuration
133    ###       4. Keyword arguments
134    _create_engine_args = flavor_configs.get(self.flavor, {}).get('create_engine', {})
135    def _apply_create_engine_args(update):
136        if 'ALL' not in flavor_configs[self.flavor].get('omit_create_engine', {}):
137            _create_engine_args.update(
138                { k: v for k, v in update.items()
139                    if 'omit_create_engine' not in flavor_configs[self.flavor]
140                        or k not in flavor_configs[self.flavor].get('omit_create_engine')
141                }
142            )
143    _apply_create_engine_args(self._sys_config.get('create_engine', {}))
144    _apply_create_engine_args(self.__dict__.get('create_engine', {}))
145    _apply_create_engine_args(_kw_copy)
146
147    try:
148        engine = sqlalchemy.create_engine(
149            engine_str,
150            ### I know this looks confusing, and maybe it's bad code,
151            ### but it's simple. It dynamically parses the config string
152            ### and splits it to separate the class name (QueuePool)
153            ### from the module name (sqlalchemy.pool).
154            poolclass    = getattr(
155                attempt_import(
156                    ".".join(self._sys_config['poolclass'].split('.')[:-1])
157                ),
158                self._sys_config['poolclass'].split('.')[-1]
159            ),
160            echo         = debug,
161            **_create_engine_args
162        )
163    except Exception:
164        warn(f"Failed to create connector '{self}':\n{traceback.format_exc()}", stack=False)
165        engine = None
166
167    if include_uri:
168        return engine, engine_str
169    return engine

Create a sqlalchemy engine by building the engine string.

def read( self, query_or_table: 'Union[str, sqlalchemy.Query]', params: Union[Dict[str, Any], List[str], NoneType] = None, dtype: Optional[Dict[str, Any]] = None, coerce_float: bool = True, chunksize: Optional[int] = -1, workers: Optional[int] = None, chunk_hook: Optional[Callable[[pandas.DataFrame], Any]] = None, as_hook_results: bool = False, chunks: Optional[int] = None, schema: Optional[str] = None, as_chunks: bool = False, as_iterator: bool = False, as_dask: bool = False, index_col: Optional[str] = None, silent: bool = False, debug: bool = False, **kw: Any) -> 'Union[pandas.DataFrame, dask.DataFrame, List[pandas.DataFrame], List[Any], None]':
 35def read(
 36    self,
 37    query_or_table: Union[str, sqlalchemy.Query],
 38    params: Union[Dict[str, Any], List[str], None] = None,
 39    dtype: Optional[Dict[str, Any]] = None,
 40    coerce_float: bool = True,
 41    chunksize: Optional[int] = -1,
 42    workers: Optional[int] = None,
 43    chunk_hook: Optional[Callable[[pandas.DataFrame], Any]] = None,
 44    as_hook_results: bool = False,
 45    chunks: Optional[int] = None,
 46    schema: Optional[str] = None,
 47    as_chunks: bool = False,
 48    as_iterator: bool = False,
 49    as_dask: bool = False,
 50    index_col: Optional[str] = None,
 51    silent: bool = False,
 52    debug: bool = False,
 53    **kw: Any
 54) -> Union[
 55    pandas.DataFrame,
 56    dask.DataFrame,
 57    List[pandas.DataFrame],
 58    List[Any],
 59    None,
 60]:
 61    """
 62    Read a SQL query or table into a pandas dataframe.
 63
 64    Parameters
 65    ----------
 66    query_or_table: Union[str, sqlalchemy.Query]
 67        The SQL query (sqlalchemy Query or string) or name of the table from which to select.
 68
 69    params: Optional[Dict[str, Any]], default None
 70        `List` or `Dict` of parameters to pass to `pandas.read_sql()`.
 71        See the pandas documentation for more information:
 72        https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_sql.html
 73
 74    dtype: Optional[Dict[str, Any]], default None
 75        A dictionary of data types to pass to `pandas.read_sql()`.
 76        See the pandas documentation for more information:
 77        https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_sql_query.html
 78
 79    chunksize: Optional[int], default -1
 80        How many chunks to read at a time. `None` will read everything in one large chunk.
 81        Defaults to system configuration.
 82
 83        **NOTE:** DuckDB does not allow for chunking.
 84
 85    workers: Optional[int], default None
 86        How many threads to use when consuming the generator.
 87        Only applies if `chunk_hook` is provided.
 88
 89    chunk_hook: Optional[Callable[[pandas.DataFrame], Any]], default None
 90        Hook function to execute once per chunk, e.g. writing and reading chunks intermittently.
 91        See `--sync-chunks` for an example.
 92        **NOTE:** `as_iterator` MUST be False (default).
 93
 94    as_hook_results: bool, default False
 95        If `True`, return a `List` of the outputs of the hook function.
 96        Only applicable if `chunk_hook` is not None.
 97
 98        **NOTE:** `as_iterator` MUST be `False` (default).
 99
100    chunks: Optional[int], default None
101        Limit the number of chunks to read into memory, i.e. how many chunks to retrieve and
102        return into a single dataframe.
103        For example, to limit the returned dataframe to 100,000 rows,
104        you could specify a `chunksize` of `1000` and `chunks` of `100`.
105
106    schema: Optional[str], default None
107        If just a table name is provided, optionally specify the table schema.
108        Defaults to `SQLConnector.schema`.
109
110    as_chunks: bool, default False
111        If `True`, return a list of DataFrames.
112        Otherwise return a single DataFrame.
113
114    as_iterator: bool, default False
115        If `True`, return the pandas DataFrame iterator.
116        `chunksize` must not be `None` (falls back to 1000 if so),
117        and hooks are not called in this case.
118
119    index_col: Optional[str], default None
120        If using Dask, use this column as the index column.
121        If omitted, a Pandas DataFrame will be fetched and converted to a Dask DataFrame.
122
123    silent: bool, default False
124        If `True`, don't raise warnings in case of errors.
125        Defaults to `False`.
126
127    Returns
128    -------
129    A `pd.DataFrame` (default case), or an iterator, or a list of dataframes / iterators,
130    or `None` if something breaks.
131
132    """
133    if chunks is not None and chunks <= 0:
134        return []
135
136    from meerschaum.utils.sql import sql_item_name, truncate_item_name
137    from meerschaum.utils.dtypes import are_dtypes_equal, coerce_timezone
138    from meerschaum.utils.dtypes.sql import TIMEZONE_NAIVE_FLAVORS
139    from meerschaum.utils.packages import attempt_import, import_pandas
140    from meerschaum.utils.pool import get_pool
141    from meerschaum.utils.dataframe import chunksize_to_npartitions, get_numeric_cols
142    from meerschaum.utils.misc import filter_arguments
143    import warnings
144    import traceback
145    from decimal import Decimal
146
147    pd = import_pandas()
148    is_dask = 'dask' in pd.__name__
149    dd = pd if is_dask else None
150    pandas = attempt_import('pandas')
151    npartitions = chunksize_to_npartitions(chunksize)
152    if is_dask:
153        chunksize = None
154
155    schema = schema or self.schema
156    utc_dt_cols = [
157        col
158        for col, typ in dtype.items()
159        if are_dtypes_equal(typ, 'datetime') and 'utc' in typ.lower()
160    ] if dtype else []
161
162    if dtype and utc_dt_cols and self.flavor in TIMEZONE_NAIVE_FLAVORS:
163        dtype = dtype.copy()
164        for col in utc_dt_cols:
165            dtype[col] = 'datetime64[us]'
166
167    pool = get_pool(workers=workers)
168    sqlalchemy = attempt_import("sqlalchemy", lazy=False)
169    default_chunksize = self._sys_config.get('chunksize', None)
170    chunksize = chunksize if chunksize != -1 else default_chunksize
171    if chunksize is None and as_iterator:
172        if not silent and self.flavor not in _disallow_chunks_flavors:
173            warn(
174                "An iterator may only be generated if chunksize is not None.\n"
175                + "Falling back to a chunksize of 1000.", stacklevel=3,
176            )
177        chunksize = 1000
178    if chunksize is not None and self.flavor in _max_chunks_flavors:
179        if chunksize > _max_chunks_flavors[self.flavor]:
180            if chunksize != default_chunksize:
181                warn(
182                    f"The specified chunksize of {chunksize} exceeds the maximum of "
183                    + f"{_max_chunks_flavors[self.flavor]} for flavor '{self.flavor}'.\n"
184                    + f"    Falling back to a chunksize of {_max_chunks_flavors[self.flavor]}.",
185                    stacklevel=3,
186                )
187            chunksize = _max_chunks_flavors[self.flavor]
188
189    if chunksize is not None and self.flavor in _disallow_chunks_flavors:
190        chunksize = None
191
192    if debug:
193        import time
194        start = time.perf_counter()
195        dprint(f"[{self}]\n{query_or_table}")
196        dprint(f"[{self}] Fetching with chunksize: {chunksize}")
197
198    ### This might be sqlalchemy object or the string of a table name.
199    ### We check for spaces and quotes to see if it might be a weird table.
200    if (
201        ' ' not in str(query_or_table)
202        or (
203            ' ' in str(query_or_table)
204            and str(query_or_table).startswith('"')
205            and str(query_or_table).endswith('"')
206        )
207    ):
208        truncated_table_name = truncate_item_name(str(query_or_table), self.flavor)
209        if truncated_table_name != str(query_or_table) and not silent:
210            if self.flavor not in ('oracle', 'mysql', 'mariadb'):
211                warn(
212                    f"Table '{query_or_table}' is too long for '{self.flavor}',"
213                    + f" will instead read the table '{truncated_table_name}'."
214                )
215
216        query_or_table = sql_item_name(str(query_or_table), self.flavor, schema)
217        if debug:
218            dprint(f"[{self}] Reading from table {query_or_table}")
219        formatted_query = sqlalchemy.text("SELECT * FROM " + str(query_or_table))
220        str_query = f"SELECT * FROM {query_or_table}"
221    else:
222        str_query = query_or_table
223
224    formatted_query = (
225        sqlalchemy.text(str_query)
226        if not is_dask and isinstance(str_query, str)
227        else format_sql_query_for_dask(str_query)
228    )
229
230    def _get_chunk_args_kwargs(_chunk):
231        return filter_arguments(
232            chunk_hook,
233            _chunk,
234            workers=workers,
235            chunksize=chunksize,
236            debug=debug,
237            **kw
238        )
239
240    chunk_list = []
241    chunk_hook_results = []
242    def _process_chunk(_chunk, _retry_on_failure: bool = True):
243        if self.flavor in TIMEZONE_NAIVE_FLAVORS:
244            for col in utc_dt_cols:
245                _chunk[col] = coerce_timezone(_chunk[col], strip_utc=False)
246        if not as_hook_results:
247            chunk_list.append(_chunk)
248
249        if chunk_hook is None:
250            return None
251
252        chunk_args, chunk_kwargs = _get_chunk_args_kwargs(_chunk)
253
254        result = None
255        try:
256            result = chunk_hook(*chunk_args, **chunk_kwargs)
257        except Exception:
258            result = False, traceback.format_exc()
259            from meerschaum.utils.formatting import get_console
260            if not silent:
261                get_console().print_exception()
262
263        ### If the chunk fails to process, try it again one more time.
264        if isinstance(result, tuple) and result[0] is False:
265            if _retry_on_failure:
266                return _process_chunk(_chunk, _retry_on_failure=False)
267
268        return result
269
270    try:
271        stream_results = not as_iterator and chunk_hook is not None and chunksize is not None
272        with warnings.catch_warnings():
273            warnings.filterwarnings('ignore', 'case sensitivity issues')
274
275            read_sql_query_kwargs = {
276                'params': params,
277                'dtype': dtype,
278                'coerce_float': coerce_float,
279                'index_col': index_col,
280            }
281            if is_dask:
282                if index_col is None:
283                    dd = None
284                    pd = attempt_import('pandas')
285                    read_sql_query_kwargs.update({
286                        'chunksize': chunksize,
287                    })
288            else:
289                read_sql_query_kwargs.update({
290                    'chunksize': chunksize,
291                })
292
293            if is_dask and dd is not None:
294                ddf = dd.read_sql_query(
295                    formatted_query,
296                    self.URI,
297                    **read_sql_query_kwargs
298                )
299            else:
300
301                def get_chunk_generator(connectable):
302                    chunk_generator = pd.read_sql_query(
303                        formatted_query,
304                        connectable, # NOTE: test this against `self.engine`.
305                        **read_sql_query_kwargs
306                    )
307
308                    to_return = (
309                        iter((chunk_generator,))
310                        if chunksize is None and as_iterator
311                        else (
312                            chunk_generator
313                            if as_iterator
314                            else (
315                                list(pool.imap(_process_chunk, chunk_generator))
316                                if as_hook_results and chunksize is not None
317                                else None
318                            )
319                        )
320                    )
321                    return chunk_generator, to_return
322
323                if self.flavor in SKIP_READ_TRANSACTION_FLAVORS:
324                    chunk_generator, to_return = get_chunk_generator(self.engine)
325                else:
326                    with self.engine.begin() as transaction:
327                        with transaction.execution_options(
328                            stream_results=stream_results,
329                        ) as connection:
330                            chunk_generator, to_return = get_chunk_generator(connection)
331
332                if to_return is not None:
333                    return to_return
334
335    except Exception as e:
336        if debug:
337            dprint(f"[{self}] Failed to execute query:\n\n{query_or_table}\n\n")
338        if not silent:
339            warn(str(e), stacklevel=3)
340        from meerschaum.utils.formatting import get_console
341        if not silent:
342            get_console().print_exception()
343
344        return None
345
346    if is_dask and dd is not None:
347        ddf = ddf.reset_index()
348        return ddf
349
350    chunk_list = []
351    read_chunks = 0
352    chunk_hook_results = []
353    if chunksize is None:
354        chunk_list.append(chunk_generator)
355    elif as_iterator:
356        return chunk_generator
357    else:
358        try:
359            for chunk in chunk_generator:
360                if chunk_hook is not None:
361                    chunk_args, chunk_kwargs = _get_chunk_args_kwargs(chunk)
362                    chunk_hook_results.append(chunk_hook(*chunk_args, **chunk_kwargs))
363                chunk_list.append(chunk)
364                read_chunks += 1
365                if chunks is not None and read_chunks >= chunks:
366                    break
367        except Exception as e:
368            warn(f"[{self}] Failed to retrieve query results:\n" + str(e), stacklevel=3)
369            from meerschaum.utils.formatting import get_console
370            if not silent:
371                get_console().print_exception()
372
373    ### If no chunks returned, read without chunks
374    ### to get columns
375    if len(chunk_list) == 0:
376        with warnings.catch_warnings():
377            warnings.filterwarnings('ignore', 'case sensitivity issues')
378            _ = read_sql_query_kwargs.pop('chunksize', None)
379            with self.engine.begin() as connection:
380                chunk_list.append(
381                    pd.read_sql_query(
382                        formatted_query,
383                        connection,
384                        **read_sql_query_kwargs
385                    )
386                )
387
388    ### call the hook on any missed chunks.
389    if chunk_hook is not None and len(chunk_list) > len(chunk_hook_results):
390        for c in chunk_list[len(chunk_hook_results):]:
391            chunk_args, chunk_kwargs = _get_chunk_args_kwargs(c)
392            chunk_hook_results.append(chunk_hook(*chunk_args, **chunk_kwargs))
393
394    ### chunksize is not None so must iterate
395    if debug:
396        end = time.perf_counter()
397        dprint(f"Fetched {len(chunk_list)} chunks in {round(end - start, 2)} seconds.")
398
399    if as_hook_results:
400        return chunk_hook_results
401    
402    ### Skip `pd.concat()` if `as_chunks` is specified.
403    if as_chunks:
404        for c in chunk_list:
405            c.reset_index(drop=True, inplace=True)
406            for col in get_numeric_cols(c):
407                c[col] = c[col].apply(lambda x: x.canonical() if isinstance(x, Decimal) else x)
408        return chunk_list
409
410    df = pd.concat(chunk_list).reset_index(drop=True)
411    ### NOTE: The calls to `canonical()` are to drop leading and trailing zeroes.
412    for col in get_numeric_cols(df):
413        df[col] = df[col].apply(lambda x: x.canonical() if isinstance(x, Decimal) else x)
414
415    return df

Read a SQL query or table into a pandas dataframe.

Parameters
  • query_or_table (Union[str, sqlalchemy.Query]): The SQL query (sqlalchemy Query or string) or name of the table from which to select.
  • params (Optional[Dict[str, Any]], default None): List or Dict of parameters to pass to pandas.read_sql(). See the pandas documentation for more information: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_sql.html
  • dtype (Optional[Dict[str, Any]], default None): A dictionary of data types to pass to pandas.read_sql(). See the pandas documentation for more information: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_sql_query.html
  • chunksize (Optional[int], default -1): How many chunks to read at a time. None will read everything in one large chunk. Defaults to system configuration.

    NOTE: DuckDB does not allow for chunking.

  • workers (Optional[int], default None): How many threads to use when consuming the generator. Only applies if chunk_hook is provided.
  • chunk_hook (Optional[Callable[[pandas.DataFrame], Any]], default None): Hook function to execute once per chunk, e.g. writing and reading chunks intermittently. See --sync-chunks for an example. NOTE: as_iterator MUST be False (default).
  • as_hook_results (bool, default False): If True, return a List of the outputs of the hook function. Only applicable if chunk_hook is not None.

    NOTE: as_iterator MUST be False (default).

  • chunks (Optional[int], default None): Limit the number of chunks to read into memory, i.e. how many chunks to retrieve and return into a single dataframe. For example, to limit the returned dataframe to 100,000 rows, you could specify a chunksize of 1000 and chunks of 100.
  • schema (Optional[str], default None): If just a table name is provided, optionally specify the table schema. Defaults to SQLConnector.schema.
  • as_chunks (bool, default False): If True, return a list of DataFrames. Otherwise return a single DataFrame.
  • as_iterator (bool, default False): If True, return the pandas DataFrame iterator. chunksize must not be None (falls back to 1000 if so), and hooks are not called in this case.
  • index_col (Optional[str], default None): If using Dask, use this column as the index column. If omitted, a Pandas DataFrame will be fetched and converted to a Dask DataFrame.
  • silent (bool, default False): If True, don't raise warnings in case of errors. Defaults to False.
Returns
  • A pd.DataFrame (default case), or an iterator, or a list of dataframes / iterators,
  • or None if something breaks.
def value(self, query: str, *args: Any, use_pandas: bool = False, **kw: Any) -> Any:
418def value(
419    self,
420    query: str,
421    *args: Any,
422    use_pandas: bool = False,
423    **kw: Any
424) -> Any:
425    """
426    Execute the provided query and return the first value.
427
428    Parameters
429    ----------
430    query: str
431        The SQL query to execute.
432        
433    *args: Any
434        The arguments passed to `meerschaum.connectors.sql.SQLConnector.exec`
435        if `use_pandas` is `False` (default) or to `meerschaum.connectors.sql.SQLConnector.read`.
436        
437    use_pandas: bool, default False
438        If `True`, use `meerschaum.connectors.SQLConnector.read`, otherwise use
439        `meerschaum.connectors.sql.SQLConnector.exec` (default).
440        **NOTE:** This is always `True` for DuckDB.
441
442    **kw: Any
443        See `args`.
444
445    Returns
446    -------
447    Any value returned from the query.
448
449    """
450    from meerschaum.utils.packages import attempt_import
451    if self.flavor == 'duckdb':
452        use_pandas = True
453    if use_pandas:
454        try:
455            return self.read(query, *args, **kw).iloc[0, 0]
456        except Exception:
457            return None
458
459    _close = kw.get('close', True)
460    _commit = kw.get('commit', (self.flavor != 'mssql'))
461
462    try:
463        result, connection = self.exec(
464            query,
465            *args,
466            with_connection=True,
467            close=False,
468            commit=_commit,
469            **kw
470        )
471        first = result.first() if result is not None else None
472        _val = first[0] if first is not None else None
473    except Exception as e:
474        warn(e, stacklevel=3)
475        return None
476    if _close:
477        try:
478            connection.close()
479        except Exception as e:
480            warn("Failed to close connection with exception:\n" + str(e))
481    return _val

Execute the provided query and return the first value.

Parameters
Returns
  • Any value returned from the query.
def exec( self, query: str, *args: Any, silent: bool = False, debug: bool = False, commit: Optional[bool] = None, close: Optional[bool] = None, with_connection: bool = False, _connection=None, _transaction=None, **kw: Any) -> 'Union[sqlalchemy.engine.result.resultProxy, sqlalchemy.engine.cursor.LegacyCursorResult, Tuple[sqlalchemy.engine.result.resultProxy, sqlalchemy.engine.base.Connection], Tuple[sqlalchemy.engine.cursor.LegacyCursorResult, sqlalchemy.engine.base.Connection], None]':
495def exec(
496    self,
497    query: str,
498    *args: Any,
499    silent: bool = False,
500    debug: bool = False,
501    commit: Optional[bool] = None,
502    close: Optional[bool] = None,
503    with_connection: bool = False,
504    _connection=None,
505    _transaction=None,
506    **kw: Any
507) -> Union[
508        sqlalchemy.engine.result.resultProxy,
509        sqlalchemy.engine.cursor.LegacyCursorResult,
510        Tuple[sqlalchemy.engine.result.resultProxy, sqlalchemy.engine.base.Connection],
511        Tuple[sqlalchemy.engine.cursor.LegacyCursorResult, sqlalchemy.engine.base.Connection],
512        None
513]:
514    """
515    Execute SQL code and return the `sqlalchemy` result, e.g. when calling stored procedures.
516
517    If inserting data, please use bind variables to avoid SQL injection!
518
519    Parameters
520    ----------
521    query: Union[str, List[str], Tuple[str]]
522        The query to execute.
523        If `query` is a list or tuple, call `self.exec_queries()` instead.
524
525    args: Any
526        Arguments passed to `sqlalchemy.engine.execute`.
527
528    silent: bool, default False
529        If `True`, suppress warnings.
530
531    commit: Optional[bool], default None
532        If `True`, commit the changes after execution.
533        Causes issues with flavors like `'mssql'`.
534        This does not apply if `query` is a list of strings.
535
536    close: Optional[bool], default None
537        If `True`, close the connection after execution.
538        Causes issues with flavors like `'mssql'`.
539        This does not apply if `query` is a list of strings.
540
541    with_connection: bool, default False
542        If `True`, return a tuple including the connection object.
543        This does not apply if `query` is a list of strings.
544
545    Returns
546    -------
547    The `sqlalchemy` result object, or a tuple with the connection if `with_connection` is provided.
548
549    """
550    if isinstance(query, (list, tuple)):
551        return self.exec_queries(
552            list(query),
553            *args,
554            silent=silent,
555            debug=debug,
556            **kw
557        )
558
559    from meerschaum.utils.packages import attempt_import
560    sqlalchemy = attempt_import("sqlalchemy", lazy=False)
561    if debug:
562        dprint(f"[{self}] Executing query:\n{query}")
563
564    _close = close if close is not None else (self.flavor != 'mssql')
565    _commit = commit if commit is not None else (
566        (self.flavor != 'mssql' or 'select' not in str(query).lower())
567    )
568
569    ### Select and Insert objects need to be compiled (SQLAlchemy 2.0.0+).
570    if not hasattr(query, 'compile'):
571        query = sqlalchemy.text(query)
572
573    connection = _connection if _connection is not None else self.get_connection()
574
575    try:
576        transaction = (
577            _transaction
578            if _transaction is not None else (
579                connection.begin()
580                if _commit
581                else None
582            )
583        )
584    except sqlalchemy.exc.InvalidRequestError as e:
585        if _connection is not None or _transaction is not None:
586            raise e
587        connection = self.get_connection(rebuild=True)
588        transaction = connection.begin()
589
590    if transaction is not None and not transaction.is_active and _transaction is not None:
591        connection = self.get_connection(rebuild=True)
592        transaction = connection.begin() if _commit else None
593
594    result = None
595    try:
596        result = connection.execute(query, *args, **kw)
597        if _commit:
598            transaction.commit()
599    except Exception as e:
600        if debug:
601            dprint(f"[{self}] Failed to execute query:\n\n{query}\n\n{e}")
602        if not silent:
603            warn(str(e), stacklevel=3)
604        result = None
605        if _commit:
606            if debug:
607                dprint(f"[{self}] Rolling back failed transaction...")
608            transaction.rollback()
609            connection = self.get_connection(rebuild=True)
610    finally:
611        if _close:
612            connection.close()
613
614    if debug:
615        dprint(f"[{self}] Done executing.")
616
617    if with_connection:
618        return result, connection
619
620    return result

Execute SQL code and return the sqlalchemy result, e.g. when calling stored procedures.

If inserting data, please use bind variables to avoid SQL injection!

Parameters
  • query (Union[str, List[str], Tuple[str]]): The query to execute. If query is a list or tuple, call self.exec_queries() instead.
  • args (Any): Arguments passed to sqlalchemy.engine.execute.
  • silent (bool, default False): If True, suppress warnings.
  • commit (Optional[bool], default None): If True, commit the changes after execution. Causes issues with flavors like 'mssql'. This does not apply if query is a list of strings.
  • close (Optional[bool], default None): If True, close the connection after execution. Causes issues with flavors like 'mssql'. This does not apply if query is a list of strings.
  • with_connection (bool, default False): If True, return a tuple including the connection object. This does not apply if query is a list of strings.
Returns
  • The sqlalchemy result object, or a tuple with the connection if with_connection is provided.
def execute( self, *args: Any, **kw: Any) -> 'Optional[sqlalchemy.engine.result.resultProxy]':
484def execute(
485    self,
486    *args : Any,
487    **kw : Any
488) -> Optional[sqlalchemy.engine.result.resultProxy]:
489    """
490    An alias for `meerschaum.connectors.sql.SQLConnector.exec`.
491    """
492    return self.exec(*args, **kw)
def to_sql( self, df: pandas.DataFrame, name: str = None, index: bool = False, if_exists: str = 'replace', method: str = '', chunksize: Optional[int] = -1, schema: Optional[str] = None, safe_copy: bool = True, silent: bool = False, debug: bool = False, as_tuple: bool = False, as_dict: bool = False, _connection=None, _transaction=None, **kw) -> Union[bool, Tuple[bool, str]]:
 724def to_sql(
 725    self,
 726    df: pandas.DataFrame,
 727    name: str = None,
 728    index: bool = False,
 729    if_exists: str = 'replace',
 730    method: str = "",
 731    chunksize: Optional[int] = -1,
 732    schema: Optional[str] = None,
 733    safe_copy: bool = True,
 734    silent: bool = False,
 735    debug: bool = False,
 736    as_tuple: bool = False,
 737    as_dict: bool = False,
 738    _connection=None,
 739    _transaction=None,
 740    **kw
 741) -> Union[bool, SuccessTuple]:
 742    """
 743    Upload a DataFrame's contents to the SQL server.
 744
 745    Parameters
 746    ----------
 747    df: pd.DataFrame
 748        The DataFrame to be inserted.
 749
 750    name: str
 751        The name of the table to be created.
 752
 753    index: bool, default False
 754        If True, creates the DataFrame's indices as columns.
 755
 756    if_exists: str, default 'replace'
 757        Drop and create the table ('replace') or append if it exists
 758        ('append') or raise Exception ('fail').
 759        Options are ['replace', 'append', 'fail'].
 760
 761    method: str, default ''
 762        None or multi. Details on pandas.to_sql.
 763
 764    chunksize: Optional[int], default -1
 765        How many rows to insert at a time.
 766
 767    schema: Optional[str], default None
 768        Optionally override the schema for the table.
 769        Defaults to `SQLConnector.schema`.
 770
 771    safe_copy: bool, defaul True
 772        If `True`, copy the dataframe before making any changes.
 773
 774    as_tuple: bool, default False
 775        If `True`, return a (success_bool, message) tuple instead of a `bool`.
 776        Defaults to `False`.
 777
 778    as_dict: bool, default False
 779        If `True`, return a dictionary of transaction information.
 780        The keys are `success`, `msg`, `start`, `end`, `duration`, `num_rows`, `chunksize`,
 781        `method`, and `target`.
 782
 783    kw: Any
 784        Additional arguments will be passed to the DataFrame's `to_sql` function
 785
 786    Returns
 787    -------
 788    Either a `bool` or a `SuccessTuple` (depends on `as_tuple`).
 789    """
 790    import time
 791    import json
 792    from datetime import timedelta
 793    from meerschaum.utils.warnings import error, warn
 794    import warnings
 795    import functools
 796    import traceback
 797
 798    if name is None:
 799        error(f"Name must not be `None` to insert data into {self}.")
 800
 801    ### We're requiring `name` to be positional, and sometimes it's passed in from background jobs.
 802    kw.pop('name', None)
 803
 804    schema = schema or self.schema
 805
 806    from meerschaum.utils.sql import (
 807        sql_item_name,
 808        table_exists,
 809        json_flavors,
 810        truncate_item_name,
 811        DROP_IF_EXISTS_FLAVORS,
 812    )
 813    from meerschaum.utils.dataframe import (
 814        get_json_cols,
 815        get_numeric_cols,
 816        get_uuid_cols,
 817        get_bytes_cols,
 818        get_geometry_cols,
 819    )
 820    from meerschaum.utils.dtypes import (
 821        are_dtypes_equal,
 822        coerce_timezone,
 823        encode_bytes_for_bytea,
 824        serialize_bytes,
 825        serialize_decimal,
 826        serialize_geometry,
 827        json_serialize_value,
 828        get_geometry_type_srid,
 829    )
 830    from meerschaum.utils.dtypes.sql import (
 831        PD_TO_SQLALCHEMY_DTYPES_FLAVORS,
 832        TIMEZONE_NAIVE_FLAVORS,
 833        get_db_type_from_pd_type,
 834        get_pd_type_from_db_type,
 835        get_numeric_precision_scale,
 836    )
 837    from meerschaum.utils.misc import interval_str
 838    from meerschaum.connectors.sql._create_engine import flavor_configs
 839    from meerschaum.utils.packages import attempt_import, import_pandas
 840    sqlalchemy = attempt_import('sqlalchemy', debug=debug, lazy=False)
 841    pd = import_pandas()
 842    is_dask = 'dask' in df.__module__
 843
 844    bytes_cols = get_bytes_cols(df)
 845    numeric_cols = get_numeric_cols(df)
 846    geometry_cols = get_geometry_cols(df)
 847    ### NOTE: This excludes non-numeric serialized Decimals (e.g. SQLite).
 848    numeric_cols_dtypes = {
 849        col: typ
 850        for col, typ in kw.get('dtype', {}).items()
 851        if (
 852            col in df.columns
 853            and 'numeric' in str(typ).lower()
 854        )
 855    }
 856    numeric_cols.extend([col for col in numeric_cols_dtypes if col not in numeric_cols])
 857    numeric_cols_precisions_scales = {
 858        col: (
 859            (typ.precision, typ.scale)
 860            if hasattr(typ, 'precision')
 861            else get_numeric_precision_scale(self.flavor)
 862        )
 863        for col, typ in numeric_cols_dtypes.items()
 864    }
 865    geometry_cols_dtypes = {
 866        col: typ
 867        for col, typ in kw.get('dtype', {}).items()
 868        if (
 869            col in df.columns
 870            and ('geometry' in str(typ).lower() or 'geography' in str(typ).lower())
 871        )
 872    }
 873    geometry_cols.extend([col for col in geometry_cols_dtypes if col not in geometry_cols])
 874    geometry_cols_types_srids = {
 875        col: (typ.geometry_type, typ.srid)
 876        if hasattr(typ, 'srid')
 877        else get_geometry_type_srid()
 878        for col, typ in geometry_cols_dtypes.items()
 879    }
 880
 881    if self.flavor in TIMEZONE_NAIVE_FLAVORS:
 882        for col, typ in df.dtypes.items():
 883            if are_dtypes_equal(str(typ), 'datetime'):
 884                df[col] = coerce_timezone(df[col], strip_utc=True)
 885
 886    cols_pd_types = {
 887        col: get_pd_type_from_db_type(str(typ))
 888        for col, typ in kw.get('dtype', {}).items()
 889    }
 890    cols_pd_types.update({
 891        col: f'numeric[{precision},{scale}]'
 892        for col, (precision, scale) in numeric_cols_precisions_scales.items()
 893        if precision and scale
 894    })
 895    cols_db_types = {
 896        col: get_db_type_from_pd_type(typ, flavor=self.flavor)
 897        for col, typ in cols_pd_types.items()
 898    }
 899
 900    enable_bulk_insert = mrsm.get_config(
 901        'system', 'connectors', 'sql', 'bulk_insert', self.flavor,
 902        warn=False,
 903    ) or False
 904    stats = {'target': name}
 905    ### resort to defaults if None
 906    copied = False
 907    use_bulk_insert = False
 908    if method == "":
 909        if enable_bulk_insert:
 910            method = (
 911                functools.partial(mssql_insert_json, cols_types=cols_db_types, debug=debug)
 912                if self.flavor == 'mssql'
 913                else functools.partial(psql_insert_copy, debug=debug)
 914            )
 915            use_bulk_insert = True
 916        else:
 917            ### Should resolve to 'multi' or `None`.
 918            method = flavor_configs.get(self.flavor, {}).get('to_sql', {}).get('method', 'multi')
 919
 920    if bytes_cols and (use_bulk_insert or self.flavor == 'oracle'):
 921        if safe_copy and not copied:
 922            df = df.copy()
 923            copied = True
 924        bytes_serializer = (
 925            functools.partial(encode_bytes_for_bytea, with_prefix=(self.flavor != 'oracle'))
 926            if self.flavor != 'mssql'
 927            else serialize_bytes
 928        )
 929        for col in bytes_cols:
 930            df[col] = df[col].apply(bytes_serializer)
 931
 932    ### Check for numeric columns.
 933    for col in numeric_cols:
 934        precision, scale = numeric_cols_precisions_scales.get(
 935            col,
 936            get_numeric_precision_scale(self.flavor)
 937        )
 938        df[col] = df[col].apply(
 939            functools.partial(
 940                serialize_decimal,
 941                quantize=True,
 942                precision=precision,
 943                scale=scale,
 944            )
 945        )
 946
 947    geometry_format = 'wkt' if self.flavor == 'mssql' else (
 948        'gpkg_wkb'
 949        if self.flavor == 'geopackage'
 950        else 'wkb_hex'
 951    )
 952    for col in geometry_cols:
 953        geometry_type, srid = geometry_cols_types_srids.get(col, get_geometry_type_srid())
 954        with warnings.catch_warnings():
 955            warnings.simplefilter("ignore")
 956            df[col] = df[col].apply(
 957                functools.partial(
 958                    serialize_geometry,
 959                    geometry_format=geometry_format,
 960                )
 961            )
 962
 963    stats['method'] = method.__name__ if hasattr(method, '__name__') else str(method)
 964
 965    default_chunksize = self._sys_config.get('chunksize', None)
 966    chunksize = chunksize if chunksize != -1 else default_chunksize
 967    if chunksize is not None and self.flavor in _max_chunks_flavors:
 968        if chunksize > _max_chunks_flavors[self.flavor]:
 969            if chunksize != default_chunksize:
 970                warn(
 971                    f"The specified chunksize of {chunksize} exceeds the maximum of "
 972                    + f"{_max_chunks_flavors[self.flavor]} for flavor '{self.flavor}'.\n"
 973                    + f"    Falling back to a chunksize of {_max_chunks_flavors[self.flavor]}.",
 974                    stacklevel = 3,
 975                )
 976            chunksize = _max_chunks_flavors[self.flavor]
 977    stats['chunksize'] = chunksize
 978
 979    success, msg = False, "Default to_sql message"
 980    start = time.perf_counter()
 981    if debug:
 982        msg = f"[{self}] Inserting {len(df)} rows with chunksize: {chunksize}..."
 983        print(msg, end="", flush=True)
 984    stats['num_rows'] = len(df)
 985
 986    ### Check if the name is too long.
 987    truncated_name = truncate_item_name(name, self.flavor)
 988    if name != truncated_name:
 989        if self.flavor not in ('oracle', 'mysql', 'mariadb'):
 990            warn(
 991                f"Table '{name}' is too long for '{self.flavor}',"
 992                f" will instead create the table '{truncated_name}'."
 993            )
 994
 995    ### filter out non-pandas args
 996    import inspect
 997    to_sql_params = inspect.signature(df.to_sql).parameters
 998    to_sql_kw = {}
 999    for k, v in kw.items():
1000        if k in to_sql_params:
1001            to_sql_kw[k] = v
1002
1003    to_sql_kw.update({
1004        'name': truncated_name,
1005        'schema': schema,
1006        ('con' if not is_dask else 'uri'): (self.engine if not is_dask else self.URI),
1007        'index': index,
1008        'if_exists': if_exists,
1009        'method': method,
1010        'chunksize': chunksize,
1011    })
1012    if is_dask:
1013        to_sql_kw.update({
1014            'parallel': True,
1015        })
1016    elif _connection is not None:
1017        to_sql_kw['con'] = _connection
1018
1019    if_exists_str = "IF EXISTS" if self.flavor in DROP_IF_EXISTS_FLAVORS else ""
1020    if self.flavor == 'oracle':
1021        ### For some reason 'replace' doesn't work properly in pandas,
1022        ### so try dropping first.
1023        if if_exists == 'replace' and table_exists(name, self, schema=schema, debug=debug):
1024            success = self.exec(
1025                f"DROP TABLE {if_exists_str}" + sql_item_name(name, 'oracle', schema)
1026            ) is not None
1027            if not success:
1028                warn(f"Unable to drop {name}")
1029
1030        ### Enforce NVARCHAR(2000) as text instead of CLOB.
1031        dtype = to_sql_kw.get('dtype', {})
1032        for col, typ in df.dtypes.items():
1033            if are_dtypes_equal(str(typ), 'object'):
1034                dtype[col] = sqlalchemy.types.NVARCHAR(2000)
1035            elif are_dtypes_equal(str(typ), 'int'):
1036                dtype[col] = sqlalchemy.types.INTEGER
1037        to_sql_kw['dtype'] = dtype
1038    elif self.flavor == 'duckdb':
1039        dtype = to_sql_kw.get('dtype', {})
1040        dt_cols = [col for col, typ in df.dtypes.items() if are_dtypes_equal(str(typ), 'datetime')]
1041        for col in dt_cols:
1042            df[col] = coerce_timezone(df[col], strip_utc=False)
1043    elif self.flavor == 'mssql':
1044        dtype = to_sql_kw.get('dtype', {})
1045        dt_cols = [col for col, typ in df.dtypes.items() if are_dtypes_equal(str(typ), 'datetime')]
1046        new_dtype = {}
1047        for col in dt_cols:
1048            if col in dtype:
1049                continue
1050            dt_typ = get_db_type_from_pd_type(str(df.dtypes[col]), self.flavor, as_sqlalchemy=True)
1051            if col not in dtype:
1052                new_dtype[col] = dt_typ
1053
1054        dtype.update(new_dtype)
1055        to_sql_kw['dtype'] = dtype
1056
1057    ### Check for JSON columns.
1058    if self.flavor not in json_flavors:
1059        json_cols = get_json_cols(df)
1060        for col in json_cols:
1061            df[col] = df[col].apply(
1062                (
1063                    lambda x: json.dumps(x, default=json_serialize_value, sort_keys=True)
1064                    if not isinstance(x, Hashable)
1065                    else x
1066                )
1067            )
1068
1069    if PD_TO_SQLALCHEMY_DTYPES_FLAVORS['uuid'].get(self.flavor, None) != 'Uuid':
1070        uuid_cols = get_uuid_cols(df)
1071        for col in uuid_cols:
1072            df[col] = df[col].astype(str)
1073
1074    try:
1075        with warnings.catch_warnings():
1076            warnings.filterwarnings('ignore')
1077            df.to_sql(**to_sql_kw)
1078        success = True
1079    except Exception:
1080        if not silent:
1081            warn(traceback.format_exc())
1082        success, msg = False, traceback.format_exc()
1083
1084    end = time.perf_counter()
1085    if success:
1086        num_rows = len(df)
1087        msg = (
1088            f"It took {interval_str(timedelta(seconds=(end - start)))} "
1089            + f"to sync {num_rows:,} row"
1090            + ('s' if num_rows != 1 else '')
1091            + f" to {name}."
1092        )
1093    stats['start'] = start
1094    stats['end'] = end
1095    stats['duration'] = end - start
1096
1097    if debug:
1098        print(" done.", flush=True)
1099        dprint(msg)
1100
1101    stats['success'] = success
1102    stats['msg'] = msg
1103    if as_tuple:
1104        return success, msg
1105    if as_dict:
1106        return stats
1107    return success

Upload a DataFrame's contents to the SQL server.

Parameters
  • df (pd.DataFrame): The DataFrame to be inserted.
  • name (str): The name of the table to be created.
  • index (bool, default False): If True, creates the DataFrame's indices as columns.
  • if_exists (str, default 'replace'): Drop and create the table ('replace') or append if it exists ('append') or raise Exception ('fail'). Options are ['replace', 'append', 'fail'].
  • method (str, default ''): None or multi. Details on pandas.to_sql.
  • chunksize (Optional[int], default -1): How many rows to insert at a time.
  • schema (Optional[str], default None): Optionally override the schema for the table. Defaults to SQLConnector.schema.
  • safe_copy (bool, defaul True): If True, copy the dataframe before making any changes.
  • as_tuple (bool, default False): If True, return a (success_bool, message) tuple instead of a bool. Defaults to False.
  • as_dict (bool, default False): If True, return a dictionary of transaction information. The keys are success, msg, start, end, duration, num_rows, chunksize, method, and target.
  • kw (Any): Additional arguments will be passed to the DataFrame's to_sql function
Returns
  • Either a bool or a SuccessTuple (depends on as_tuple).
def exec_queries( self, queries: List[Union[str, Tuple[str, Callable[[sqlalchemy.orm.session.Session], List[str]]]]], break_on_error: bool = False, rollback: bool = True, silent: bool = False, debug: bool = False) -> List[Optional[sqlalchemy.engine.cursor.CursorResult]]:
623def exec_queries(
624    self,
625    queries: List[
626        Union[
627            str,
628            Tuple[str, Callable[['sqlalchemy.orm.session.Session'], List[str]]]
629        ]
630    ],
631    break_on_error: bool = False,
632    rollback: bool = True,
633    silent: bool = False,
634    debug: bool = False,
635) -> List[Union[sqlalchemy.engine.cursor.CursorResult, None]]:
636    """
637    Execute a list of queries in a single transaction.
638
639    Parameters
640    ----------
641    queries: List[
642        Union[
643            str,
644            Tuple[str, Callable[[], List[str]]]
645        ]
646    ]
647        The queries in the transaction to be executed.
648        If a query is a tuple, the second item of the tuple
649        will be considered a callable hook that returns a list of queries to be executed
650        before the next item in the list.
651
652    break_on_error: bool, default False
653        If `True`, stop executing when a query fails.
654
655    rollback: bool, default True
656        If `break_on_error` is `True`, rollback the transaction if a query fails.
657
658    silent: bool, default False
659        If `True`, suppress warnings.
660
661    Returns
662    -------
663    A list of SQLAlchemy results.
664    """
665    from meerschaum.utils.warnings import warn
666    from meerschaum.utils.debug import dprint
667    from meerschaum.utils.packages import attempt_import
668    sqlalchemy, sqlalchemy_orm = attempt_import('sqlalchemy', 'sqlalchemy.orm', lazy=False)
669    session = sqlalchemy_orm.Session(self.engine)
670
671    result = None
672    results = []
673    with session.begin():
674        for query in queries:
675            hook = None
676            result = None
677
678            if isinstance(query, tuple):
679                query, hook = query
680            if isinstance(query, str):
681                query = sqlalchemy.text(query)
682
683            if debug:
684                dprint(f"[{self}]\n" + str(query))
685
686            try:
687                result = session.execute(query)
688                session.flush()
689            except Exception as e:
690                msg = (f"Encountered error while executing:\n{e}")
691                if not silent:
692                    warn(msg)
693                elif debug:
694                    dprint(f"[{self}]\n" + str(msg))
695                result = None
696
697            if debug:
698                dprint(f"[{self}] Finished executing.")
699
700            if result is None and break_on_error:
701                if rollback:
702                    if debug:
703                        dprint(f"[{self}] Rolling back...")
704                    session.rollback()
705                results.append(result)
706                break
707            elif result is not None and hook is not None:
708                hook_queries = hook(session)
709                if hook_queries:
710                    hook_results = self.exec_queries(
711                        hook_queries,
712                        break_on_error = break_on_error,
713                        rollback=rollback,
714                        silent=silent,
715                        debug=debug,
716                    )
717                    result = (result, hook_results)
718
719            results.append(result)
720
721    return results

Execute a list of queries in a single transaction.

Parameters
  • queries (List[): Union[ str, Tuple[str, Callable[[], List[str]]] ]
  • ]: The queries in the transaction to be executed. If a query is a tuple, the second item of the tuple will be considered a callable hook that returns a list of queries to be executed before the next item in the list.
  • break_on_error (bool, default False): If True, stop executing when a query fails.
  • rollback (bool, default True): If break_on_error is True, rollback the transaction if a query fails.
  • silent (bool, default False): If True, suppress warnings.
Returns
  • A list of SQLAlchemy results.
def get_connection(self, rebuild: bool = False) -> sqlalchemy.engine.base.Connection:
1305def get_connection(self, rebuild: bool = False) -> 'sqlalchemy.engine.base.Connection':
1306    """
1307    Return the current alive connection.
1308
1309    Parameters
1310    ----------
1311    rebuild: bool, default False
1312        If `True`, close the previous connection and open a new one.
1313
1314    Returns
1315    -------
1316    A `sqlalchemy.engine.base.Connection` object.
1317    """
1318    import threading
1319    if '_thread_connections' not in self.__dict__:
1320        self.__dict__['_thread_connections'] = {}
1321
1322    self._cleanup_connections()
1323
1324    thread_id = threading.get_ident()
1325
1326    thread_connections = self.__dict__.get('_thread_connections', {})
1327    connection = thread_connections.get(thread_id, None)
1328
1329    if rebuild and connection is not None:
1330        try:
1331            connection.close()
1332        except Exception:
1333            pass
1334
1335        _ = thread_connections.pop(thread_id, None)
1336        connection = None
1337
1338    if connection is None or connection.closed:
1339        connection = self.engine.connect()
1340        thread_connections[thread_id] = connection
1341
1342    return connection

Return the current alive connection.

Parameters
  • rebuild (bool, default False): If True, close the previous connection and open a new one.
Returns
  • A sqlalchemy.engine.base.Connection object.
def test_connection(self, **kw: Any) -> Optional[bool]:
871def test_connection(
872    self,
873    **kw: Any
874) -> Union[bool, None]:
875    """
876    Test if a successful connection to the database may be made.
877
878    Parameters
879    ----------
880    **kw:
881        The keyword arguments are passed to `meerschaum.connectors.poll.retry_connect`.
882
883    Returns
884    -------
885    `True` if a connection is made, otherwise `False` or `None` in case of failure.
886
887    """
888    import warnings
889    from meerschaum.connectors.poll import retry_connect
890    _default_kw = {'max_retries': 1, 'retry_wait': 0, 'warn': False, 'connector': self}
891    _default_kw.update(kw)
892    with warnings.catch_warnings():
893        warnings.filterwarnings('ignore', 'Could not')
894        try:
895            return retry_connect(**_default_kw)
896        except Exception:
897            return False

Test if a successful connection to the database may be made.

Parameters
Returns
  • True if a connection is made, otherwise False or None in case of failure.
def fetch( self, pipe: meerschaum.Pipe, begin: Union[datetime.datetime, int, str, NoneType] = '', end: Union[datetime.datetime, int, str, NoneType] = None, check_existing: bool = True, chunksize: Optional[int] = -1, workers: Optional[int] = None, debug: bool = False, **kw: Any) -> "Union['pd.DataFrame', List[Any], None]":
18def fetch(
19    self,
20    pipe: mrsm.Pipe,
21    begin: Union[datetime, int, str, None] = '',
22    end: Union[datetime, int, str, None] = None,
23    check_existing: bool = True,
24    chunksize: Optional[int] = -1,
25    workers: Optional[int] = None,
26    debug: bool = False,
27    **kw: Any
28) -> Union['pd.DataFrame', List[Any], None]:
29    """Execute the SQL definition and return a Pandas DataFrame.
30
31    Parameters
32    ----------
33    pipe: mrsm.Pipe
34        The pipe object which contains the `fetch` metadata.
35
36        - pipe.columns['datetime']: str
37            - Name of the datetime column for the remote table.
38        - pipe.parameters['fetch']: Dict[str, Any]
39            - Parameters necessary to execute a query.
40        - pipe.parameters['fetch']['definition']: str
41            - Raw SQL query to execute to generate the pandas DataFrame.
42        - pipe.parameters['fetch']['backtrack_minutes']: Union[int, float]
43            - How many minutes before `begin` to search for data (*optional*).
44
45    begin: Union[datetime, int, str, None], default None
46        Most recent datatime to search for data.
47        If `backtrack_minutes` is provided, subtract `backtrack_minutes`.
48
49    end: Union[datetime, int, str, None], default None
50        The latest datetime to search for data.
51        If `end` is `None`, do not bound 
52
53    check_existing: bool, defult True
54        If `False`, use a backtrack interval of 0 minutes.
55
56    chunksize: Optional[int], default -1
57        How many rows to load into memory at once.
58        Otherwise the entire result set is loaded into memory.
59
60    workers: Optional[int], default None
61        How many threads to use when consuming the generator.
62        Defaults to the number of cores.
63
64    debug: bool, default False
65        Verbosity toggle.
66
67    Returns
68    -------
69    A pandas DataFrame generator.
70    """
71    meta_def = self.get_pipe_metadef(
72        pipe,
73        begin=begin,
74        end=end,
75        check_existing=check_existing,
76        debug=debug,
77        **kw
78    )
79    chunks = self.read(
80        meta_def,
81        chunksize=chunksize,
82        workers=workers,
83        as_iterator=True,
84        debug=debug,
85    )
86    return chunks

Execute the SQL definition and return a Pandas DataFrame.

Parameters
  • pipe (mrsm.Pipe): The pipe object which contains the fetch metadata.

    • pipe.columns['datetime']: str
      • Name of the datetime column for the remote table.
    • pipe.parameters['fetch']: Dict[str, Any]
      • Parameters necessary to execute a query.
    • pipe.parameters['fetch']['definition']: str
      • Raw SQL query to execute to generate the pandas DataFrame.
    • pipe.parameters['fetch']['backtrack_minutes']: Union[int, float]
      • How many minutes before begin to search for data (optional).
  • begin (Union[datetime, int, str, None], default None): Most recent datatime to search for data. If backtrack_minutes is provided, subtract backtrack_minutes.
  • end (Union[datetime, int, str, None], default None): The latest datetime to search for data. If end is None, do not bound
  • check_existing (bool, defult True): If False, use a backtrack interval of 0 minutes.
  • chunksize (Optional[int], default -1): How many rows to load into memory at once. Otherwise the entire result set is loaded into memory.
  • workers (Optional[int], default None): How many threads to use when consuming the generator. Defaults to the number of cores.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A pandas DataFrame generator.
def get_pipe_metadef( self, pipe: meerschaum.Pipe, params: Optional[Dict[str, Any]] = None, begin: Union[datetime.datetime, int, str, NoneType] = '', end: Union[datetime.datetime, int, str, NoneType] = None, check_existing: bool = True, debug: bool = False, **kw: Any) -> Optional[str]:
 89def get_pipe_metadef(
 90    self,
 91    pipe: mrsm.Pipe,
 92    params: Optional[Dict[str, Any]] = None,
 93    begin: Union[datetime, int, str, None] = '',
 94    end: Union[datetime, int, str, None] = None,
 95    check_existing: bool = True,
 96    debug: bool = False,
 97    **kw: Any
 98) -> Union[str, None]:
 99    """
100    Return a pipe's meta definition fetch query.
101
102    params: Optional[Dict[str, Any]], default None
103        Optional params dictionary to build the `WHERE` clause.
104        See `meerschaum.utils.sql.build_where`.
105
106    begin: Union[datetime, int, str, None], default None
107        Most recent datatime to search for data.
108        If `backtrack_minutes` is provided, subtract `backtrack_minutes`.
109
110    end: Union[datetime, int, str, None], default None
111        The latest datetime to search for data.
112        If `end` is `None`, do not bound 
113
114    check_existing: bool, default True
115        If `True`, apply the backtrack interval.
116
117    debug: bool, default False
118        Verbosity toggle.
119
120    Returns
121    -------
122    A pipe's meta definition fetch query string.
123    """
124    from meerschaum.utils.warnings import warn
125    from meerschaum.utils.sql import (
126        sql_item_name,
127        dateadd_str,
128        build_where,
129        wrap_query_with_cte,
130        format_cte_subquery,
131    )
132    from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type
133    from meerschaum.config import get_config
134    from meerschaum.utils.dtypes import (
135        get_current_timestamp,
136        MRSM_PRECISION_UNITS_SCALARS,
137        MRSM_PRECISION_UNITS_ALIASES,
138    )
139
140    parent = pipe.parent
141    parent_dt_col = parent.columns.get('datetime', None) if parent is not None else None
142    parent_dt_typ = parent.dtypes.get(parent_dt_col, 'datetime') if parent_dt_col else None
143    dt_col = parent_dt_col or pipe.columns.get('datetime', None)
144    dt_typ = parent_dt_typ or pipe.dtypes.get(dt_col, 'datetime')
145    db_dt_typ = get_db_type_from_pd_type(dt_typ, self.flavor) if dt_typ else None
146    precision = parent.precision if parent_dt_typ else pipe.precision
147    if not dt_col:
148        dt_col = pipe.guess_datetime()
149        dt_name = sql_item_name(dt_col, self.flavor, None) if dt_col else None
150        is_guess = True
151    else:
152        dt_name = sql_item_name(dt_col, self.flavor, None)
153        is_guess = False
154
155    if begin not in (None, '') or end is not None:
156        if is_guess:
157            if dt_col is None:
158                warn(
159                    f"Unable to determine a datetime column for {pipe}."
160                    + "\n    Ignoring begin and end...",
161                    stack=False,
162                )
163                begin, end = '', None
164            else:
165                warn(
166                    f"A datetime wasn't specified for {pipe}.\n"
167                    + f"    Using column \"{dt_col}\" for datetime bounds...",
168                    stack=False
169                )
170
171    apply_backtrack = begin == '' and check_existing
172    backtrack_interval = pipe.get_backtrack_interval(check_existing=check_existing, debug=debug)
173    btm = (
174        int(backtrack_interval.total_seconds() / 60)
175        if isinstance(backtrack_interval, timedelta)
176        else backtrack_interval
177    )
178    begin = (
179        pipe.get_sync_time(debug=debug)
180        if begin == ''
181        else begin
182    )
183
184    if 'int' in dt_typ.lower():
185        precision_unit = precision.get('unit', 'second')
186        if isinstance(begin, datetime):
187            begin = get_current_timestamp(precision_unit, _now=begin, as_int=True)
188        if isinstance(end, datetime):
189            end = get_current_timestamp(precision_unit, _now=end, as_int=True)
190
191        if isinstance(backtrack_interval, timedelta):
192            true_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit)
193            btm = int(backtrack_interval.total_seconds() * MRSM_PRECISION_UNITS_SCALARS[true_unit])
194
195    if begin not in (None, '') and end is not None and begin >= end:
196        begin = None
197
198    begin_da, end_da = None, None
199    if dt_name:
200        begin_da = (
201            dateadd_str(
202                flavor=self.flavor,
203                datepart=('minute' if not isinstance(begin, int) else None),
204                number=((-1 * btm) if apply_backtrack else 0),
205                begin=begin,
206                db_type=db_dt_typ,
207            )
208            if begin not in ('', None)
209            else None
210        )
211        end_da = (
212            dateadd_str(
213                flavor=self.flavor,
214                datepart=('minute' if not isinstance(end, int) else None),
215                number=0,
216                begin=end,
217                db_type=db_dt_typ,
218            )
219            if end is not None
220            else None
221        )
222
223    definition_name = sql_item_name('definition', self.flavor, None)
224    definition = get_pipe_query(pipe)
225    if definition is None:
226        raise ValueError(f"No SQL definition could be found for {pipe}.")
227
228    ### Attempt to push down the predicate if possible.
229    handled_bounding = False
230    if parent_dt_col and (begin not in (None, '') or end is not None):
231        parent_target = parent.target
232        parent_schema = (
233            parent.instance_connector.get_pipe_schema(parent)
234            if parent.instance_connector.type == 'sql'
235            else None
236        )
237        parent_dt_name = sql_item_name(parent_dt_col, self.flavor, None)
238        parent_db_dt_typ = get_db_type_from_pd_type(parent_dt_typ, self.flavor)
239
240        p_begin, p_end, p_btm = begin, end, btm
241        if 'int' in parent_dt_typ.lower():
242            p_precision_unit = precision.get('unit', 'second')
243            if isinstance(begin, (datetime, int)):
244                _dt_begin = (
245                    begin
246                    if isinstance(begin, datetime)
247                    else datetime.fromtimestamp(
248                        begin / MRSM_PRECISION_UNITS_SCALARS[
249                            MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit)
250                        ],
251                        timezone.utc
252                    )
253                )
254                p_begin = get_current_timestamp(p_precision_unit, _now=_dt_begin, as_int=True)
255            
256            if isinstance(end, (datetime, int)) and end is not None:
257                _dt_end = (
258                    end
259                    if isinstance(end, datetime)
260                    else datetime.fromtimestamp(
261                        end / MRSM_PRECISION_UNITS_SCALARS[
262                            MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit)
263                        ], 
264                        timezone.utc
265                    )
266                )
267                p_end = get_current_timestamp(p_precision_unit, _now=_dt_end, as_int=True)
268
269            if isinstance(backtrack_interval, timedelta):
270                p_true_unit = MRSM_PRECISION_UNITS_ALIASES.get(p_precision_unit, p_precision_unit)
271                p_btm = int(
272                    backtrack_interval.total_seconds()
273                    * MRSM_PRECISION_UNITS_SCALARS[p_true_unit]
274                )
275
276        parent_begin_da = (
277            dateadd_str(
278                flavor=self.flavor,
279                datepart='minute',
280                number=((-1 * p_btm) if apply_backtrack else 0),
281                begin=p_begin,
282                db_type=parent_db_dt_typ,
283            )
284            if p_begin not in ('', None)
285            else None
286        )
287        parent_end_da = (
288            dateadd_str(
289                flavor=self.flavor,
290                datepart='minute',
291                number=0,
292                begin=p_end,
293                db_type=parent_db_dt_typ,
294            )
295            if p_end is not None
296            else None
297        )
298
299        parent_item_name = sql_item_name(parent_target, self.flavor, None)
300        parent_item_name_full = sql_item_name(parent_target, self.flavor, parent_schema)
301
302        # Simple string search for parent target in the original definition.
303        if parent_dt_name and (
304            parent_item_name in definition 
305            or parent_item_name_full in definition
306            or parent_target in definition
307        ):
308            pushdown_cte_name = sql_item_name('_mrsm_pushdown', self.flavor, None)
309            pushdown_where = ""
310            if parent_begin_da:
311                pushdown_where += f"\n    {parent_dt_name} >= {parent_begin_da}"
312            if parent_begin_da and parent_end_da:
313                pushdown_where += "\n    AND"
314            if parent_end_da:
315                pushdown_where += f"\n    {parent_dt_name} < {parent_end_da}"
316            
317            pushdown_query = (
318                f"SELECT *\nFROM {parent_item_name_full}"
319                + f"\nWHERE {pushdown_where}"
320            )
321            
322            # Replace occurrences of parent target with pushdown CTE in the definition body.
323            parent_found = False
324            patterns_to_replace = [
325                parent_item_name_full,
326                parent_item_name,
327                parent_target,
328            ]
329
330            new_definition_body = definition
331            for pattern in patterns_to_replace:
332                if pattern in new_definition_body:
333                    new_definition_body = new_definition_body.replace(pattern, pushdown_cte_name)
334                    parent_found = True
335            
336            if parent_found:
337                definition = wrap_query_with_cte(
338                    pushdown_query,
339                    new_definition_body,
340                    self.flavor,
341                    cte_name='_mrsm_pushdown',
342                )
343                handled_bounding = True
344
345    meta_def = (
346        format_cte_subquery(definition, self.flavor, 'definition') if (
347            (not (pipe.columns or {}).get('id', None))
348            or (not get_config('system', 'experimental', 'join_fetch'))
349        ) else _join_fetch_query(pipe, self.flavor, debug=debug, **kw)
350    )
351
352    has_where = 'where' in meta_def.lower()[meta_def.lower().rfind('definition'):]
353    if dt_name and (begin_da or end_da) and not handled_bounding:
354        definition_dt_name = f"{definition_name}.{dt_name}"
355        meta_def += "\n" + ("AND" if has_where else "WHERE") + " "
356        has_where = True
357        if begin_da:
358            meta_def += f"\n    {definition_dt_name}\n    >=\n    {begin_da}\n"
359        if begin_da and end_da:
360            meta_def += "    AND"
361        if end_da:
362            meta_def += f"\n    {definition_dt_name}\n    <\n    {end_da}\n"
363
364    if params is not None:
365        params_where = build_where(params, self, with_where=False)
366        meta_def += "\n    " + ("AND" if has_where else "WHERE") + "    "
367        has_where = True
368        meta_def += params_where
369
370    return meta_def.rstrip()

Return a pipe's meta definition fetch query.

params: Optional[Dict[str, Any]], default None Optional params dictionary to build the WHERE clause. See meerschaum.utils.sql.build_where.

begin: Union[datetime, int, str, None], default None Most recent datatime to search for data. If backtrack_minutes is provided, subtract backtrack_minutes.

end: Union[datetime, int, str, None], default None The latest datetime to search for data. If end is None, do not bound

check_existing: bool, default True If True, apply the backtrack interval.

debug: bool, default False Verbosity toggle.

Returns
  • A pipe's meta definition fetch query string.
def cli(self, debug: bool = False) -> Tuple[bool, str]:
39def cli(
40    self,
41    debug: bool = False,
42) -> SuccessTuple:
43    """
44    Launch a subprocess for an interactive CLI.
45    """
46    from meerschaum.utils.warnings import dprint
47    from meerschaum.utils.venv import venv_exec
48
49    ### Initialize the engine so that dependencies are resolved.
50    _ = self.engine
51
52    env = copy.deepcopy(dict(os.environ))
53    env_key = f"MRSM_SQL_{self.label.upper()}"
54    env_val = json.dumps(self.meta)
55    env[env_key] = env_val
56    cli_code = (
57        "import sys\n"
58        "import meerschaum as mrsm\n"
59        "import os\n"
60        f"conn = mrsm.get_connector('sql:{self.label}')\n"
61        "success, msg = conn._cli_exit()\n"
62        "mrsm.pprint((success, msg))\n"
63        "if not success:\n"
64        "    raise Exception(msg)"
65    )
66    if debug:
67        dprint(cli_code)
68    try:
69        _ = venv_exec(cli_code, venv=None, env=env, debug=debug, capture_output=False)
70    except Exception as e:
71        return False, f"[{self}] Failed to start CLI:\n{e}"
72    return True, "Success"

Launch a subprocess for an interactive CLI.

def get_pipe_size( self, pipe: meerschaum.Pipe, debug: bool = False, **kwargs: Any) -> Optional[int]:
187def get_pipe_size(
188    self,
189    pipe: mrsm.Pipe,
190    debug: bool = False,
191    **kwargs: Any
192) -> Union[int, None]:
193    """
194    Return the on-disk size of a pipe's target table in bytes.
195
196    For TimescaleDB hypertables, the total hypertable size (including chunks and indexes)
197    is returned. Other flavors use their native size functions where available.
198
199    Parameters
200    ----------
201    pipe: mrsm.Pipe
202        The pipe whose target table size to measure.
203
204    debug: bool, default False
205        Verbosity toggle.
206
207    Returns
208    -------
209    An `int` of the number of bytes occupied by the target table,
210    or `None` if the size could not be determined.
211    """
212    from meerschaum.utils.sql import sql_item_name, hypertable_queries
213
214    if not pipe.exists(debug=debug):
215        return None
216
217    flavor = self.flavor
218    schema = self.get_pipe_schema(pipe)
219    pipe_name = sql_item_name(pipe.target, flavor, schema)
220
221    def _value(query: str) -> Union[int, None]:
222        try:
223            result = self.value(query, silent=True, debug=debug)
224            return int(result) if result is not None else None
225        except Exception:
226            return None
227
228    ### TimescaleDB / Citus expose dedicated size functions for distributed tables.
229    if flavor in hypertable_queries:
230        size = _value(hypertable_queries[flavor].format(table_name=pipe_name))
231        if size is not None:
232            return size
233
234    if flavor in ('timescaledb', 'timescaledb-ha', 'postgresql', 'postgis', 'citus'):
235        ### `pg_partition_tree` sums the parent plus every child partition (a partitioned parent
236        ### holds no rows itself); it returns the single relation for non-partitioned tables.
237        size = _value(
238            "SELECT SUM(pg_total_relation_size(relid))\n"
239            f"FROM pg_partition_tree('{pipe_name}')"
240        )
241        if size is not None:
242            return size
243        return _value(f"SELECT pg_total_relation_size('{pipe_name}')")
244
245    if flavor == 'cockroachdb':
246        return _value(f"SELECT pg_total_relation_size('{pipe_name}')")
247
248    if flavor in ('mysql', 'mariadb'):
249        ### A MySQL/MariaDB "schema" is a database; honor a pipe's configured schema so the size
250        ### lookup matches the database the table actually lives in.
251        db_name = (
252            self.get_pipe_schema(pipe)
253            or self.database
254            or self.parse_uri(self.URI).get('database', None)
255        )
256        if not db_name:
257            return None
258        clean_db = db_name.replace("'", "''")
259        clean_target = pipe.target.replace("'", "''")
260        return _value(
261            "SELECT data_length + index_length\n"
262            "FROM information_schema.tables\n"
263            f"WHERE table_schema = '{clean_db}' AND table_name = '{clean_target}'"
264        )
265
266    if flavor == 'mssql':
267        clean_name = pipe_name.replace("'", "''")
268        return _value(
269            "SELECT SUM(reserved_page_count) * 8192\n"
270            "FROM sys.dm_db_partition_stats\n"
271            f"WHERE object_id = OBJECT_ID('{clean_name}')"
272        )
273
274    if flavor in ('sqlite', 'geopackage'):
275        clean_target = pipe.target.replace("'", "''")
276        ### `dbstat` is only available when SQLite is compiled with SQLITE_ENABLE_DBSTAT_VTAB.
277        return _value(f"SELECT SUM(pgsize) FROM dbstat WHERE name = '{clean_target}'")
278
279    ### duckdb, oracle, and unknown flavors have no portable per-table size query.
280    return None

Return the on-disk size of a pipe's target table in bytes.

For TimescaleDB hypertables, the total hypertable size (including chunks and indexes) is returned. Other flavors use their native size functions where available.

Parameters
  • pipe (mrsm.Pipe): The pipe whose target table size to measure.
  • debug (bool, default False): Verbosity toggle.
Returns
  • An int of the number of bytes occupied by the target table,
  • or None if the size could not be determined.
def compress_pipe( self, pipe: meerschaum.Pipe, no_policy: bool = False, debug: bool = False, **kwargs: Any) -> Tuple[bool, str]:
574def compress_pipe(
575    self,
576    pipe: mrsm.Pipe,
577    no_policy: bool = False,
578    debug: bool = False,
579    **kwargs: Any
580) -> SuccessTuple:
581    """
582    Compress a pipe's target table to reduce disk usage.
583
584    For TimescaleDB, enables the Hypercore columnstore, installs a columnstore (compression)
585    policy (so future synced chunks are converted automatically), and converts any existing
586    uncompressed chunks now. For MySQL/MariaDB and MSSQL, applies the flavor's native table
587    compression. Other flavors are unsupported.
588
589    Parameters
590    ----------
591    pipe: mrsm.Pipe
592        The pipe whose target table to compress.
593
594    no_policy: bool, default False
595        If `True` (TimescaleDB only), compress existing chunks now without installing an ongoing
596        columnstore (compression) policy. Any pre-existing policy is left untouched.
597
598    debug: bool, default False
599        Verbosity toggle.
600
601    Returns
602    -------
603    A `SuccessTuple` indicating success, including the amount of disk reclaimed.
604    """
605    from meerschaum.utils.sql import sql_item_name
606    from meerschaum.utils.formatting import format_bytes
607
608    if not pipe.exists(debug=debug):
609        return False, f"{pipe} does not exist; nothing to compress."
610
611    flavor = self.flavor
612    if flavor not in COMPRESSIBLE_FLAVORS:
613        return False, f"Compression is not supported for flavor '{flavor}'."
614
615    pipe_name = sql_item_name(pipe.target, flavor, self.get_pipe_schema(pipe))
616    size_before = pipe.get_size(debug=debug)
617
618    ### Each group is run in its own transaction. TimescaleDB requires enabling the columnstore
619    ### and adding its policy in separate transactions (see timescale/timescaledb#8600).
620    query_groups: List[List[str]] = []
621    if flavor in ('timescaledb', 'timescaledb-ha'):
622        if not self._is_hypertable(pipe, debug=debug):
623            return False, _not_a_hypertable_message(pipe)
624        ### 0. An integer axis needs an `integer_now` function before any time-based policy.
625        integer_now_queries = self._get_integer_now_func_queries(pipe)
626        if integer_now_queries:
627            query_groups.append(integer_now_queries)
628        ### 1. Enable the columnstore (required before any chunk can be converted).
629        query_groups.append([self._get_columnstore_settings_query(pipe)])
630        ### 2. Install a policy for ongoing conversion — re-create it so the configured `after`
631        ### wins over any existing (e.g. auto-created) policy. Skipped entirely with `no_policy`,
632        ### which compresses existing chunks now but leaves any pre-existing policy untouched.
633        if not no_policy:
634            query_groups.append([self._get_columnstore_remove_policy_query(pipe)])
635            query_groups.append([self._get_columnstore_policy_query(pipe)])
636        ### 3. Convert any existing uncompressed chunks now. `compress_chunk` is the still-supported
637        ### function form of `convert_to_columnstore` (transaction-safe, unlike the `CALL` form).
638        query_groups.append([
639            f"SELECT compress_chunk(c, if_not_compressed => true) "
640            f"FROM show_chunks('{pipe_name}') c"
641        ])
642    elif flavor in ('mysql', 'mariadb'):
643        query_groups.append([f"ALTER TABLE {pipe_name} ROW_FORMAT=COMPRESSED"])
644    elif flavor == 'mssql':
645        query_groups.append([
646            f"ALTER TABLE {pipe_name} REBUILD PARTITION = ALL "
647            "WITH (DATA_COMPRESSION = PAGE)"
648        ])
649
650    try:
651        success = all(
652            all(self.exec_queries(
653                group, break_on_error=True, rollback=True, silent=(not debug), debug=debug,
654            ))
655            for group in query_groups
656        )
657    except Exception as e:
658        return False, f"Failed to compress {pipe}:\n{e}"
659
660    if not success:
661        return False, f"Failed to compress {pipe}."
662
663    pipe._clear_cache_key('_exists', debug=debug)
664    size_after = pipe.get_size(debug=debug)
665
666    reclaimed_msg = ""
667    if size_before is not None and size_after is not None:
668        reclaimed = size_before - size_after
669        change_str = f"{format_bytes(size_before)} to {format_bytes(size_after)}"
670        if reclaimed > 0:
671            reclaimed_msg = f"Reclaimed {format_bytes(reclaimed)} ({change_str})."
672        elif reclaimed < 0:
673            ### On small tables, compression overhead can exceed the savings.
674            reclaimed_msg = f"Size grew by {format_bytes(-reclaimed)} ({change_str})."
675        else:
676            reclaimed_msg = f"Size unchanged ({format_bytes(size_before)})."
677
678    return True, reclaimed_msg

Compress a pipe's target table to reduce disk usage.

For TimescaleDB, enables the Hypercore columnstore, installs a columnstore (compression) policy (so future synced chunks are converted automatically), and converts any existing uncompressed chunks now. For MySQL/MariaDB and MSSQL, applies the flavor's native table compression. Other flavors are unsupported.

Parameters
  • pipe (mrsm.Pipe): The pipe whose target table to compress.
  • no_policy (bool, default False): If True (TimescaleDB only), compress existing chunks now without installing an ongoing columnstore (compression) policy. Any pre-existing policy is left untouched.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A SuccessTuple indicating success, including the amount of disk reclaimed.
def decompress_pipe( self, pipe: meerschaum.Pipe, no_policy: bool = False, debug: bool = False, **kwargs: Any) -> Tuple[bool, str]:
695def decompress_pipe(
696    self,
697    pipe: mrsm.Pipe,
698    no_policy: bool = False,
699    debug: bool = False,
700    **kwargs: Any
701) -> SuccessTuple:
702    """
703    Decompress a pipe's target table, the inverse of `compress_pipe()`.
704
705    For TimescaleDB, removes the columnstore (compression) policy, converts every compressed
706    chunk back to row-store, and disables the columnstore so future synced chunks stay
707    uncompressed. For MySQL/MariaDB and MSSQL, reverts the flavor's native table compression.
708    Other flavors are unsupported.
709
710    Parameters
711    ----------
712    pipe: mrsm.Pipe
713        The pipe whose target table to decompress.
714
715    no_policy: bool, default False
716        If `True` (TimescaleDB only), decompress existing chunks now but leave the columnstore
717        (compression) policy in place — chunks will be recompressed on the policy's schedule.
718        Useful to temporarily decompress for a bulk backfill without disabling compression.
719
720    debug: bool, default False
721        Verbosity toggle.
722
723    Returns
724    -------
725    A `SuccessTuple` indicating success, including the change in disk size.
726    """
727    from meerschaum.utils.sql import sql_item_name
728    from meerschaum.utils.formatting import format_bytes
729
730    if not pipe.exists(debug=debug):
731        return False, f"{pipe} does not exist; nothing to decompress."
732
733    flavor = self.flavor
734    if flavor not in COMPRESSIBLE_FLAVORS:
735        return False, f"Decompression is not supported for flavor '{flavor}'."
736
737    pipe_name = sql_item_name(pipe.target, flavor, self.get_pipe_schema(pipe))
738    size_before = pipe.get_size(debug=debug)
739
740    ### Each group is run in its own transaction.
741    query_groups: List[List[str]] = []
742    if flavor in ('timescaledb', 'timescaledb-ha'):
743        if not self._is_hypertable(pipe, debug=debug):
744            return False, _not_a_hypertable_message(pipe)
745        ### 1. Remove the ongoing policy so chunks aren't recompressed. Skipped with `no_policy`,
746        ### which decompresses existing chunks now but leaves the policy (e.g. for a backfill).
747        if not no_policy:
748            query_groups.append([self._get_columnstore_remove_policy_query(pipe)])
749        ### 2. Convert every compressed chunk back to row-store. `decompress_chunk` is the function
750        ### form (transaction-safe, unlike the `CALL` form of `convert_to_rowstore`).
751        query_groups.append([
752            f"SELECT decompress_chunk(c, if_compressed => true) "
753            f"FROM show_chunks('{pipe_name}') c"
754        ])
755        ### 3. Disable the columnstore so future synced chunks stay uncompressed. Only valid once
756        ### no compressed chunks remain, and only sensible when the policy is also gone.
757        if not no_policy:
758            query_groups.append([self._get_columnstore_disable_query(pipe)])
759    elif flavor in ('mysql', 'mariadb'):
760        query_groups.append([f"ALTER TABLE {pipe_name} ROW_FORMAT=DYNAMIC"])
761    elif flavor == 'mssql':
762        query_groups.append([
763            f"ALTER TABLE {pipe_name} REBUILD PARTITION = ALL "
764            "WITH (DATA_COMPRESSION = NONE)"
765        ])
766
767    try:
768        success = all(
769            all(self.exec_queries(
770                group, break_on_error=True, rollback=True, silent=(not debug), debug=debug,
771            ))
772            for group in query_groups
773        )
774    except Exception as e:
775        return False, f"Failed to decompress {pipe}:\n{e}"
776
777    if not success:
778        return False, f"Failed to decompress {pipe}."
779
780    pipe._clear_cache_key('_exists', debug=debug)
781    size_after = pipe.get_size(debug=debug)
782
783    change_msg = ""
784    if size_before is not None and size_after is not None:
785        added = size_after - size_before
786        change_str = f"{format_bytes(size_before)} to {format_bytes(size_after)}"
787        if added > 0:
788            change_msg = f"Expanded by {format_bytes(added)} ({change_str})."
789        elif added < 0:
790            change_msg = f"Shrank by {format_bytes(-added)} ({change_str})."
791        else:
792            change_msg = f"Size unchanged ({format_bytes(size_before)})."
793
794    return True, change_msg

Decompress a pipe's target table, the inverse of compress_pipe().

For TimescaleDB, removes the columnstore (compression) policy, converts every compressed chunk back to row-store, and disables the columnstore so future synced chunks stay uncompressed. For MySQL/MariaDB and MSSQL, reverts the flavor's native table compression. Other flavors are unsupported.

Parameters
  • pipe (mrsm.Pipe): The pipe whose target table to decompress.
  • no_policy (bool, default False): If True (TimescaleDB only), decompress existing chunks now but leave the columnstore (compression) policy in place — chunks will be recompressed on the policy's schedule. Useful to temporarily decompress for a bulk backfill without disabling compression.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A SuccessTuple indicating success, including the change in disk size.
def apply_compression_policy( self, pipe: meerschaum.Pipe, debug: bool = False, **kwargs: Any) -> Tuple[bool, str]:
497def apply_compression_policy(
498    self,
499    pipe: mrsm.Pipe,
500    debug: bool = False,
501    **kwargs: Any
502) -> SuccessTuple:
503    """
504    Idempotently enable compression and install a compression policy for a pipe.
505
506    Intended to be called automatically (e.g. after a sync) when `pipe.compress` is set.
507    Only TimescaleDB hypertables are affected; all other flavors are a no-op success.
508    Failures are non-fatal and never raise.
509
510    Parameters
511    ----------
512    pipe: mrsm.Pipe
513        The pipe whose target table should have a compression policy.
514
515    Returns
516    -------
517    A `SuccessTuple` indicating success.
518    """
519    if self.flavor not in ('timescaledb', 'timescaledb-ha'):
520        return True, "Compression policies are only supported for TimescaleDB."
521
522    if not pipe.parameters.get('compress', False):
523        return True, "Compression is not enabled for this pipe."
524
525    ### Compressing a chunk invalidates the composite unique index the upsert's
526    ### `ON CONFLICT` needs, so an upsert which touches a compressed chunk fails.
527    ### Warn once per pipe per process; the combination is safe only while
528    ### `compress:after` exceeds the pipe's re-upsert window.
529    if pipe.upsert:
530        warned_pipes = self.__dict__.setdefault('_compress_upsert_warned_pipes', set())
531        if str(pipe) not in warned_pipes:
532            warned_pipes.add(str(pipe))
533            warn(
534                f"{pipe} declares both `upsert` and `compress`:\n    "
535                "upserts into compressed chunks will fail with "
536                "\"no unique or exclusion constraint matching the ON CONFLICT "
537                "specification\".\n    "
538                "Ensure `compress:after` exceeds how far back this pipe re-syncs history.",
539                stack=False,
540            )
541
542    try:
543        if not pipe.exists(debug=debug) or not self._is_hypertable(pipe, debug=debug):
544            return True, f"{pipe} is not a hypertable; skipping compression policy."
545
546        if not self.set_integer_now_func(pipe, debug=debug):
547            warn(
548                f"Could not register an `integer_now` function for {pipe}; "
549                "its columnstore policy will fail on every run.",
550                stack=False,
551            )
552
553        ### Enable the columnstore and add the policy in SEPARATE transactions
554        ### (see timescale/timescaledb#8600).
555        settings_success = all(self.exec_queries(
556            [self._get_columnstore_settings_query(pipe)],
557            break_on_error=True, rollback=True, silent=True, debug=debug,
558        ))
559        policy_success = all(self.exec_queries(
560            [self._get_columnstore_policy_query(pipe)],
561            break_on_error=True, rollback=True, silent=True, debug=debug,
562        ))
563        if not (settings_success and policy_success):
564            return False, f"Failed to apply a compression policy to {pipe}."
565    except Exception as e:
566        msg = f"Failed to apply a compression policy to {pipe}:\n{e}"
567        if debug:
568            dprint(msg)
569        return False, msg
570
571    return True, f"Applied a compression policy to {pipe}."

Idempotently enable compression and install a compression policy for a pipe.

Intended to be called automatically (e.g. after a sync) when pipe.compress is set. Only TimescaleDB hypertables are affected; all other flavors are a no-op success. Failures are non-fatal and never raise.

Parameters
  • pipe (mrsm.Pipe): The pipe whose target table should have a compression policy.
Returns
  • A SuccessTuple indicating success.
def set_integer_now_func(self, pipe: meerschaum.Pipe, debug: bool = False) -> bool:
474def set_integer_now_func(
475    self,
476    pipe: mrsm.Pipe,
477    debug: bool = False,
478) -> bool:
479    """
480    Register an `integer_now` function if the pipe's datetime axis is an integer.
481    Returns `True` when nothing needed doing or the function was registered.
482    """
483    queries = self._get_integer_now_func_queries(pipe)
484    if not queries:
485        return True
486
487    try:
488        return all(self.exec_queries(
489            queries, break_on_error=True, rollback=True, silent=(not debug), debug=debug,
490        ))
491    except Exception as e:
492        if debug:
493            dprint(f"Failed to set an `integer_now` function for {pipe}:\n{e}")
494        return False

Register an integer_now function if the pipe's datetime axis is an integer. Returns True when nothing needed doing or the function was registered.

def vacuum_pipe( self, pipe: meerschaum.Pipe, full: bool = False, debug: bool = False, **kwargs: Any) -> Tuple[bool, str]:
165def vacuum_pipe(
166    self,
167    pipe: mrsm.Pipe,
168    full: bool = False,
169    debug: bool = False,
170    **kwargs: Any
171) -> SuccessTuple:
172    """
173    Reclaim dead-tuple disk space from a pipe's target table.
174
175    PostgreSQL-family tables run `VACUUM` (optionally `VACUUM FULL`); TimescaleDB hypertables
176    recurse into their chunks; MySQL/MariaDB run `OPTIMIZE TABLE`; MSSQL rebuilds the table;
177    SQLite vacuums the whole database file.
178
179    Parameters
180    ----------
181    pipe: mrsm.Pipe
182        The pipe whose target table to vacuum.
183
184    full: bool, default False
185        If `True` (PostgreSQL family only), run `VACUUM FULL`, which rewrites the table and
186        returns freed space to the operating system at the cost of an exclusive lock.
187
188    debug: bool, default False
189        Verbosity toggle.
190
191    Returns
192    -------
193    A `SuccessTuple` indicating success, including the amount of disk reclaimed.
194    """
195    from meerschaum.utils.sql import sql_item_name
196    from meerschaum.utils.formatting import format_bytes
197
198    if not pipe.exists(debug=debug):
199        return False, f"{pipe} does not exist; nothing to vacuum."
200
201    flavor = self.flavor
202    if flavor not in VACUUMABLE_FLAVORS:
203        return False, f"Vacuuming is not supported for flavor '{flavor}'."
204
205    pipe_name = sql_item_name(pipe.target, flavor, self.get_pipe_schema(pipe))
206    queries = self._get_vacuum_queries(pipe, pipe_name, full=full)
207    if not queries:
208        return False, f"Vacuuming is not supported for flavor '{flavor}'."
209
210    size_before = pipe.get_size(debug=debug)
211
212    try:
213        if flavor in _AUTOCOMMIT_VACUUM_FLAVORS:
214            success = self._run_in_autocommit(queries, silent=(not debug), debug=debug)
215        else:
216            success = all(self.exec_queries(
217                queries, break_on_error=True, rollback=True, silent=(not debug), debug=debug,
218            ))
219    except Exception as e:
220        return False, f"Failed to vacuum {pipe}:\n{e}"
221
222    if not success:
223        return False, f"Failed to vacuum {pipe}."
224
225    pipe._clear_cache_key('_exists', debug=debug)
226    size_after = pipe.get_size(debug=debug)
227
228    reclaimed_msg = f"Vacuumed {pipe}."
229    if size_before is not None and size_after is not None:
230        reclaimed = size_before - size_after
231        change_str = f"{format_bytes(size_before)} to {format_bytes(size_after)}"
232        if reclaimed > 0:
233            reclaimed_msg = f"Reclaimed {format_bytes(reclaimed)} ({change_str})."
234        elif reclaimed < 0:
235            reclaimed_msg = f"Size grew by {format_bytes(-reclaimed)} ({change_str})."
236        else:
237            reclaimed_msg = f"Size unchanged ({format_bytes(size_before)})."
238
239    return True, reclaimed_msg

Reclaim dead-tuple disk space from a pipe's target table.

PostgreSQL-family tables run VACUUM (optionally VACUUM FULL); TimescaleDB hypertables recurse into their chunks; MySQL/MariaDB run OPTIMIZE TABLE; MSSQL rebuilds the table; SQLite vacuums the whole database file.

Parameters
  • pipe (mrsm.Pipe): The pipe whose target table to vacuum.
  • full (bool, default False): If True (PostgreSQL family only), run VACUUM FULL, which rewrites the table and returns freed space to the operating system at the cost of an exclusive lock.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A SuccessTuple indicating success, including the amount of disk reclaimed.
def analyze_pipe( self, pipe: meerschaum.Pipe, debug: bool = False, **kwargs: Any) -> Tuple[bool, str]:
242def analyze_pipe(
243    self,
244    pipe: mrsm.Pipe,
245    debug: bool = False,
246    **kwargs: Any
247) -> SuccessTuple:
248    """
249    Refresh the database planner's statistics for a pipe's target table.
250
251    This does not reclaim disk space; it helps the query planner choose better plans after
252    large syncs. PostgreSQL/SQLite run `ANALYZE`, MySQL/MariaDB run `ANALYZE TABLE`, and MSSQL
253    runs `UPDATE STATISTICS`.
254
255    Parameters
256    ----------
257    pipe: mrsm.Pipe
258        The pipe whose target table to analyze.
259
260    debug: bool, default False
261        Verbosity toggle.
262
263    Returns
264    -------
265    A `SuccessTuple` indicating success.
266    """
267    from meerschaum.utils.sql import sql_item_name
268
269    if not pipe.exists(debug=debug):
270        return False, f"{pipe} does not exist; nothing to analyze."
271
272    flavor = self.flavor
273    if flavor not in ANALYZABLE_FLAVORS:
274        return False, f"Analyzing is not supported for flavor '{flavor}'."
275
276    pipe_name = sql_item_name(pipe.target, flavor, self.get_pipe_schema(pipe))
277    query = self._get_analyze_query(pipe, pipe_name)
278    if not query:
279        return False, f"Analyzing is not supported for flavor '{flavor}'."
280
281    try:
282        success = all(self.exec_queries(
283            [query], break_on_error=True, rollback=True, silent=(not debug), debug=debug,
284        ))
285    except Exception as e:
286        return False, f"Failed to analyze {pipe}:\n{e}"
287
288    if not success:
289        return False, f"Failed to analyze {pipe}."
290
291    return True, f"Analyzed {pipe}."

Refresh the database planner's statistics for a pipe's target table.

This does not reclaim disk space; it helps the query planner choose better plans after large syncs. PostgreSQL/SQLite run ANALYZE, MySQL/MariaDB run ANALYZE TABLE, and MSSQL runs UPDATE STATISTICS.

Parameters
  • pipe (mrsm.Pipe): The pipe whose target table to analyze.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A SuccessTuple indicating success.
def get_partition_info(self, pipe: meerschaum.Pipe, debug: bool = False) -> dict:
192def get_partition_info(self, pipe: mrsm.Pipe, debug: bool = False) -> dict:
193    """
194    Return a summary of a pipe's target table partitioning for `show partitions`.
195
196    Keys:
197    - `flavor`: the connector flavor.
198    - `partitioned`: whether the table is range-partitioned (native) or a TimescaleDB hypertable.
199    - `count`: the number of partitions / chunks (`None` if unknown).
200    - `interval`: the physical partition width (`timedelta`, epoch-`int`, or `None`).
201    """
202    info = {'flavor': self.flavor, 'partitioned': False, 'count': None, 'interval': None}
203    try:
204        if not pipe.exists(debug=debug):
205            return info
206    except Exception:
207        return info
208
209    flavor = self.flavor
210    if flavor in _TIMESCALEDB_FLAVORS:
211        if not self._is_hypertable(pipe, debug=debug):
212            return info
213        info['partitioned'] = True
214        info['count'] = self._get_chunk_count_timescaledb(pipe, debug=debug)
215        info['interval'] = pipe.get_chunk_interval(debug=debug)
216        return info
217
218    if not self._should_partition(pipe):
219        return info
220    ### Report based on the table's ACTUAL state, not just the `hypertable` flag — a pre-existing
221    ### plain table (created before partitioning, or with `hypertable` only just enabled) has no
222    ### partitions and should not be reported as partitioned.
223    count = self._get_partition_count(pipe, debug=debug)
224    if not count:
225        return info
226    info['partitioned'] = True
227    info['count'] = count
228    info['interval'] = pipe.get_chunk_interval(debug=debug)
229    return info

Return a summary of a pipe's target table partitioning for show partitions.

Keys:

  • flavor: the connector flavor.
  • partitioned: whether the table is range-partitioned (native) or a TimescaleDB hypertable.
  • count: the number of partitions / chunks (None if unknown).
  • interval: the physical partition width (timedelta, epoch-int, or None).
def partition_pipe( self, pipe: meerschaum.Pipe, chunk_minutes: Optional[int] = None, debug: bool = False, **kwargs: Any) -> Tuple[bool, str]:
796def partition_pipe(
797    self,
798    pipe: mrsm.Pipe,
799    chunk_minutes: Optional[int] = None,
800    debug: bool = False,
801    **kwargs: Any
802) -> SuccessTuple:
803    """
804    Rebuild a pipe's target table to a new partition (chunk) width.
805
806    The width is taken from `chunk_minutes` if provided, else the pipe's configured
807    `verify.chunk_minutes`. The new width is persisted to `verify.chunk_minutes`, which is the
808    authoritative partition width (see `Pipe.get_chunk_interval`).
809
810    Strategy by flavor:
811
812    - **TimescaleDB**: call `set_chunk_time_interval()`. This changes the width of FUTURE chunks
813      only; existing chunks are not rewritten.
814    - **PostgreSQL / PostGIS, MySQL / MariaDB, MSSQL**: rebuild the table by reading its data,
815      dropping it, and re-syncing at the new width. This reuses the tested `create_pipe_table_from_df`
816      and `_create_missing_partitions` paths, and (for MSSQL) frees the partition function/scheme
817      names so they can be recreated. The whole table is read into memory; for very large tables
818      consider a manual chunked rebuild.
819
820    Parameters
821    ----------
822    pipe: mrsm.Pipe
823        The partitioned pipe whose target table to repartition.
824
825    chunk_minutes: Optional[int], default None
826        The new partition width in minutes. Defaults to the pipe's `verify.chunk_minutes`.
827
828    debug: bool, default False
829        Verbosity toggle.
830
831    Returns
832    -------
833    A `SuccessTuple` indicating success.
834    """
835    from meerschaum.config import get_config
836    from meerschaum.utils.warnings import warn
837
838    flavor = self.flavor
839    if flavor not in (PARTITIONABLE_FLAVORS | _TIMESCALEDB_FLAVORS):
840        return False, f"Repartitioning is not supported for flavor '{flavor}'."
841
842    is_timescaledb = flavor in _TIMESCALEDB_FLAVORS
843    if not is_timescaledb and not self._should_partition(pipe):
844        return False, (
845            f"{pipe} is not partitioned. Set `hypertable` to `True` (and define a `datetime` "
846            "column) to enable native range partitioning."
847        )
848
849    if pipe.columns.get('datetime', None) is None:
850        return False, f"{pipe} has no `datetime` column to partition by."
851
852    if not pipe.exists(debug=debug):
853        return False, f"{pipe} does not exist; nothing to repartition."
854
855    new_minutes = (
856        chunk_minutes
857        if chunk_minutes is not None
858        else (
859            pipe.parameters.get('verify', {}).get('chunk_minutes', None)
860            or get_config('pipes', 'parameters', 'verify', 'chunk_minutes')
861        )
862    )
863    if not isinstance(new_minutes, int) or new_minutes <= 0:
864        return False, f"Invalid chunk interval '{new_minutes}'; must be a positive integer of minutes."
865
866    ### TimescaleDB: native, no rewrite. Future chunks adopt the new interval.
867    if is_timescaledb:
868        from meerschaum.utils.sql import sql_item_name
869        ### `set_chunk_time_interval` takes the hypertable as a `regclass`; pass the
870        ### schema-qualified, quoted name as a string literal so it resolves unambiguously.
871        pipe_name = sql_item_name(pipe.target, flavor, self.get_pipe_schema(pipe))
872        regclass_literal = "'" + pipe_name.replace("'", "''") + "'"
873        ### Pass a duration, not a bare `int`: an `int` is taken as the axis's own units.
874        interval = pipe.get_chunk_interval(timedelta(minutes=new_minutes), debug=debug)
875        chunk_time_interval = (
876            f"{interval}"
877            if isinstance(interval, int)
878            else f"INTERVAL '{int(interval.total_seconds() / 60)} MINUTES'"
879        )
880        query = f"SELECT set_chunk_time_interval({regclass_literal}, {chunk_time_interval})"
881        try:
882            success = self.exec(query, silent=(not debug), debug=debug) is not None
883        except Exception as e:
884            return False, f"Failed to set chunk interval for {pipe}:\n{e}"
885        if not success:
886            return False, f"Failed to set chunk interval for {pipe}."
887        pipe.update_parameters(
888            {'verify': {'chunk_minutes': new_minutes}}, persist=True, debug=debug
889        )
890        return True, (
891            f"Set chunk interval for {pipe} to "
892            + (
893                f"{interval:,} epoch units"
894                if isinstance(interval, int)
895                else f"{new_minutes} minutes"
896            )
897            + " (applies to future chunks; existing chunks are unchanged)."
898        )
899
900    ### Non-TimescaleDB: rebuild via a drop + re-sync round-trip.
901    current_interval = pipe.get_chunk_interval(debug=debug)
902    new_interval = pipe.get_chunk_interval(timedelta(minutes=new_minutes), debug=debug)
903    if current_interval == new_interval:
904        return True, f"{pipe} is already partitioned at {new_minutes} minutes."
905
906    rowcount_before = pipe.get_rowcount(debug=debug)
907
908    if debug:
909        dprint(f"[{self}] Reading {pipe} data to rebuild partitions at {new_minutes} minutes.")
910    df = pipe.get_data(debug=debug)
911    if df is None:
912        return False, f"Could not read data for {pipe}; aborting repartition."
913
914    ### Persist the new width BEFORE recreating so the rebuild lays partitions at the new size.
915    ### `verify.chunk_minutes` is the authoritative partition width.
916    update_success, update_msg = pipe.update_parameters(
917        {'verify': {'chunk_minutes': new_minutes}},
918        persist=True,
919        debug=debug,
920    )
921    if not update_success:
922        return False, f"Failed to persist new partition width for {pipe}:\n{update_msg}"
923
924    drop_success, drop_msg = pipe.drop(debug=debug)
925    if not drop_success:
926        return False, f"Failed to drop {pipe} during repartition:\n{drop_msg}"
927
928    ### Re-sync the data we read; `create_pipe_table_from_df` recreates the table at the new
929    ### width and `_create_missing_partitions` populates the partitions.
930    sync_success, sync_msg = pipe.sync(df, debug=debug)
931    if not sync_success:
932        return False, (
933            f"Repartition of {pipe} failed during re-sync; the table was dropped and must be "
934            f"resynced from its source:\n{sync_msg}"
935        )
936
937    rowcount_after = pipe.get_rowcount(debug=debug)
938    if (
939        rowcount_before is not None
940        and rowcount_after is not None
941        and rowcount_after != rowcount_before
942    ):
943        warn(
944            f"Row count changed during repartition of {pipe} "
945            f"({rowcount_before} -> {rowcount_after}).",
946            stack=False,
947        )
948
949    return True, f"Repartitioned {pipe} to {new_minutes} minutes."

Rebuild a pipe's target table to a new partition (chunk) width.

The width is taken from chunk_minutes if provided, else the pipe's configured verify.chunk_minutes. The new width is persisted to verify.chunk_minutes, which is the authoritative partition width (see Pipe.get_chunk_interval).

Strategy by flavor:

  • TimescaleDB: call set_chunk_time_interval(). This changes the width of FUTURE chunks only; existing chunks are not rewritten.
  • PostgreSQL / PostGIS, MySQL / MariaDB, MSSQL: rebuild the table by reading its data, dropping it, and re-syncing at the new width. This reuses the tested create_pipe_table_from_df and _create_missing_partitions paths, and (for MSSQL) frees the partition function/scheme names so they can be recreated. The whole table is read into memory; for very large tables consider a manual chunked rebuild.
Parameters
  • pipe (mrsm.Pipe): The partitioned pipe whose target table to repartition.
  • chunk_minutes (Optional[int], default None): The new partition width in minutes. Defaults to the pipe's verify.chunk_minutes.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A SuccessTuple indicating success.
def fetch_pipes_keys( self, connector_keys: Optional[List[str]] = None, metric_keys: Optional[List[str]] = None, location_keys: Optional[List[str]] = None, tags: Optional[List[str]] = None, params: Optional[Dict[str, Any]] = None, debug: bool = False) -> Dict[int, Tuple[str, str, Optional[str], Dict[str, Any]]]:
149def fetch_pipes_keys(
150    self,
151    connector_keys: Optional[List[str]] = None,
152    metric_keys: Optional[List[str]] = None,
153    location_keys: Optional[List[str]] = None,
154    tags: Optional[List[str]] = None,
155    params: Optional[Dict[str, Any]] = None,
156    debug: bool = False,
157) -> Dict[
158        int, Tuple[str, str, Union[str, None], Dict[str, Any]]
159    ]:
160    """
161    Return a dictionary mapping pipe IDs to key tuples corresponding to the parameters provided.
162
163    Parameters
164    ----------
165    connector_keys: Optional[List[str]], default None
166        List of connector_keys to search by.
167
168    metric_keys: Optional[List[str]], default None
169        List of metric_keys to search by.
170
171    location_keys: Optional[List[str]], default None
172        List of location_keys to search by.
173
174    tags: Optional[List[str]], default None
175        List of pipes to search by.
176
177    params: Optional[Dict[str, Any]], default None
178        Dictionary of additional parameters to search by.
179        E.g. `--params pipe_id:1`
180
181    debug: bool, default False
182        Verbosity toggle.
183
184    Returns
185    -------
186    A list of tuples of pipes' keys and parameters (connector_keys, metric_key, location_key, parameters).
187    """
188    from meerschaum.utils.packages import attempt_import
189    from meerschaum.utils.misc import separate_negation_values
190    from meerschaum.utils.sql import (
191        OMIT_NULLSFIRST_FLAVORS,
192        table_exists,
193        json_flavors,
194    )
195    from meerschaum._internal.static import STATIC_CONFIG
196    import json
197    from copy import deepcopy
198    sqlalchemy, sqlalchemy_sql_functions = attempt_import(
199        'sqlalchemy',
200        'sqlalchemy.sql.functions', lazy=False,
201    )
202    coalesce = sqlalchemy_sql_functions.coalesce
203
204    if connector_keys is None:
205        connector_keys = []
206    if metric_keys is None:
207        metric_keys = []
208    if location_keys is None:
209        location_keys = []
210    else:
211        location_keys = [
212            (
213                lk
214                if lk not in ('[None]', 'None', 'null')
215                else 'None'
216            )
217            for lk in location_keys
218        ]
219    if tags is None:
220        tags = []
221
222    if params is None:
223        params = {}
224
225    ### Add three primary keys to params dictionary
226    ###   (separated for convenience of arguments).
227    cols = {
228        'connector_keys': [str(ck) for ck in connector_keys],
229        'metric_key': [str(mk) for mk in metric_keys],
230        'location_key': [str(lk) for lk in location_keys],
231    }
232
233    ### Make deep copy so we don't mutate this somewhere else.
234    parameters = deepcopy(params)
235    for col, vals in cols.items():
236        if vals not in [[], ['*']]:
237            parameters[col] = vals
238
239    if not table_exists('mrsm_pipes', self, schema=self.instance_schema, debug=debug):
240        return {}
241
242    from meerschaum.connectors.sql.tables import get_tables
243    pipes_tbl = get_tables(mrsm_instance=self, create=False, debug=debug)['pipes']
244
245    _params = {}
246    for k, v in parameters.items():
247        _v = json.dumps(v) if isinstance(v, dict) else v
248        _params[k] = _v
249
250    negation_prefix = STATIC_CONFIG['system']['fetch_pipes_keys']['negation_prefix']
251    ### Parse regular params.
252    ### If a param begins with '_', negate it instead.
253    _where = [
254        (
255            (coalesce(pipes_tbl.c[key], 'None') == val)
256            if not str(val).startswith(negation_prefix)
257            else (pipes_tbl.c[key] != key)
258        ) for key, val in _params.items()
259        if not isinstance(val, (list, tuple)) and key in pipes_tbl.c
260    ]
261    if self.flavor in json_flavors:
262        sqlalchemy_dialects = mrsm.attempt_import('sqlalchemy.dialects', lazy=False)
263        JSONB = sqlalchemy_dialects.postgresql.JSONB
264    else:
265        JSONB = sqlalchemy.String
266
267    select_cols = (
268        [
269            pipes_tbl.c.pipe_id,
270            pipes_tbl.c.connector_keys,
271            pipes_tbl.c.metric_key,
272            pipes_tbl.c.location_key,
273            pipes_tbl.c.parameters,
274        ]
275    )
276
277    q = sqlalchemy.select(*select_cols).where(sqlalchemy.and_(True, *_where))
278    for c, vals in cols.items():
279        if not isinstance(vals, (list, tuple)) or not vals or c not in pipes_tbl.c:
280            continue
281        _in_vals, _ex_vals = separate_negation_values(vals)
282        q = q.where(coalesce(pipes_tbl.c[c], 'None').in_(_in_vals)) if _in_vals else q
283        q = q.where(coalesce(pipes_tbl.c[c], 'None').not_in(_ex_vals)) if _ex_vals else q
284
285    ### Finally, parse tags.
286    tag_groups = [tag.split(',') for tag in tags]
287    in_ex_tag_groups = [separate_negation_values(tag_group) for tag_group in tag_groups]
288
289    ors, nands = [], []
290    if self.flavor in json_flavors:
291        tags_jsonb = pipes_tbl.c['parameters'].cast(JSONB).op('->')('tags').cast(JSONB)
292        for _in_tags, _ex_tags in in_ex_tag_groups:
293            if _in_tags:
294                ors.append(
295                    sqlalchemy.and_(
296                        tags_jsonb.contains(_in_tags)
297                    )
298                )
299            for xt in _ex_tags:
300                nands.append(
301                    sqlalchemy.not_(
302                        sqlalchemy.and_(
303                            tags_jsonb.contains([xt])
304                        )
305                    )
306                )
307    else:
308        for _in_tags, _ex_tags in in_ex_tag_groups:
309            sub_ands = []
310            for nt in _in_tags:
311                sub_ands.append(
312                    sqlalchemy.cast(
313                        pipes_tbl.c['parameters'],
314                        sqlalchemy.String,
315                    ).like(f'%"tags":%"{nt}"%')
316                )
317            if sub_ands:
318                ors.append(sqlalchemy.and_(*sub_ands))
319
320            for xt in _ex_tags:
321                nands.append(
322                    sqlalchemy.cast(
323                        pipes_tbl.c['parameters'],
324                        sqlalchemy.String,
325                    ).not_like(f'%"tags":%"{xt}"%')
326                )
327
328    q = q.where(sqlalchemy.and_(*nands)) if nands else q
329    q = q.where(sqlalchemy.or_(*ors)) if ors else q
330    loc_asc = sqlalchemy.asc(pipes_tbl.c['location_key'])
331    if self.flavor not in OMIT_NULLSFIRST_FLAVORS:
332        loc_asc = sqlalchemy.nullsfirst(loc_asc)
333    q = q.order_by(
334        sqlalchemy.asc(pipes_tbl.c['connector_keys']),
335        sqlalchemy.asc(pipes_tbl.c['metric_key']),
336        loc_asc,
337    )
338
339    ### execute the query and return a list of tuples
340    if debug:
341        dprint(q)
342    try:
343        rows = (
344            self.execute(q).fetchall()
345            if self.flavor != 'duckdb'
346            else [
347                (
348                    row['pipe_id'],
349                    row['connector_keys'],
350                    row['metric_key'],
351                    row['location_key'],
352                    row['parameters'],
353                )
354                for row in self.read(q).to_dict(orient='records')
355            ]
356        )
357    except Exception as e:
358        error(str(e))
359
360    return {
361        row[0]: row[1:]
362        for row in rows
363    }

Return a dictionary mapping pipe IDs to key tuples corresponding to the parameters provided.

Parameters
  • connector_keys (Optional[List[str]], default None): List of connector_keys to search by.
  • metric_keys (Optional[List[str]], default None): List of metric_keys to search by.
  • location_keys (Optional[List[str]], default None): List of location_keys to search by.
  • tags (Optional[List[str]], default None): List of pipes to search by.
  • params (Optional[Dict[str, Any]], default None): Dictionary of additional parameters to search by. E.g. --params pipe_id:1
  • debug (bool, default False): Verbosity toggle.
Returns
  • A list of tuples of pipes' keys and parameters (connector_keys, metric_key, location_key, parameters).
def create_indices( self, pipe: meerschaum.Pipe, columns: Optional[List[str]] = None, indices: Optional[List[str]] = None, debug: bool = False) -> bool:
384def create_indices(
385    self,
386    pipe: mrsm.Pipe,
387    columns: Optional[List[str]] = None,
388    indices: Optional[List[str]] = None,
389    debug: bool = False
390) -> bool:
391    """
392    Create a pipe's indices.
393    """
394    if pipe.__dict__.get('_skip_check_indices', False):
395        return True
396
397    if debug:
398        dprint(f"Creating indices for {pipe}...")
399
400    if not pipe.indices:
401        warn(f"{pipe} has no index columns; skipping index creation.", stack=False)
402        return True
403
404    cols_to_include = set((columns or []) + (indices or [])) or None
405
406    pipe._clear_cache_key('_columns_indices', debug=debug)
407    ix_queries = {
408        col: queries
409        for col, queries in self.get_create_index_queries(pipe, debug=debug).items()
410        if cols_to_include is None or col in cols_to_include
411    }
412    success = True
413    for col, queries in ix_queries.items():
414        ix_success = all(self.exec_queries(queries, debug=debug, silent=False))
415        success = success and ix_success
416        if not ix_success:
417            warn(f"Failed to create index on column: {col}")
418
419    return success

Create a pipe's indices.

def drop_indices( self, pipe: meerschaum.Pipe, columns: Optional[List[str]] = None, indices: Optional[List[str]] = None, debug: bool = False) -> bool:
440def drop_indices(
441    self,
442    pipe: mrsm.Pipe,
443    columns: Optional[List[str]] = None,
444    indices: Optional[List[str]] = None,
445    debug: bool = False
446) -> bool:
447    """
448    Drop a pipe's indices.
449    """
450    if debug:
451        dprint(f"Dropping indices for {pipe}...")
452
453    if not pipe.indices:
454        warn(f"No indices to drop for {pipe}.", stack=False)
455        return False
456
457    cols_to_include = set((columns or []) + (indices or [])) or None
458
459    ix_queries = {
460        col: queries
461        for col, queries in self.get_drop_index_queries(pipe, debug=debug).items()
462        if cols_to_include is None or col in cols_to_include
463    }
464    success = True
465    for col, queries in ix_queries.items():
466        ix_success = all(self.exec_queries(queries, debug=debug, silent=(not debug)))
467        if not ix_success:
468            success = False
469            if debug:
470                dprint(f"Failed to drop index on column: {col}")
471    return success

Drop a pipe's indices.

def get_create_index_queries( self, pipe: meerschaum.Pipe, debug: bool = False) -> Dict[str, List[str]]:
537def get_create_index_queries(
538    self,
539    pipe: mrsm.Pipe,
540    debug: bool = False,
541) -> Dict[str, List[str]]:
542    """
543    Return a dictionary mapping columns to a `CREATE INDEX` or equivalent query.
544
545    Parameters
546    ----------
547    pipe: mrsm.Pipe
548        The pipe to which the queries will correspond.
549
550    Returns
551    -------
552    A dictionary of index names mapping to lists of queries.
553    """
554    ### NOTE: Due to recent breaking changes in DuckDB, indices don't behave properly.
555    if self.flavor == 'duckdb':
556        return {}
557    from meerschaum.utils.sql import (
558        sql_item_name,
559        get_distinct_col_count,
560        UPDATE_QUERIES,
561        get_null_replacement,
562        get_create_table_queries,
563        get_rename_table_queries,
564        COALESCE_UNIQUE_INDEX_FLAVORS,
565    )
566    from meerschaum.utils.dtypes import are_dtypes_equal
567    from meerschaum.utils.dtypes.sql import (
568        get_db_type_from_pd_type,
569        get_pd_type_from_db_type,
570        AUTO_INCREMENT_COLUMN_FLAVORS,
571    )
572    from meerschaum.config import get_config
573    index_queries = {}
574
575    upsert = pipe.parameters.get('upsert', False) and (self.flavor + '-upsert') in UPDATE_QUERIES
576    static = pipe.parameters.get('static', False)
577    null_indices = pipe.parameters.get('null_indices', True)
578    index_names = pipe.get_indices()
579    unique_index_name_unquoted = index_names.get('unique', None) or f'IX_{pipe.target}_unique'
580    if upsert:
581        _ = index_names.pop('unique', None)
582    indices = pipe.indices
583    existing_cols_types = pipe.get_columns_types(debug=debug)
584    existing_cols_pd_types = {
585        col: get_pd_type_from_db_type(typ)
586        for col, typ in existing_cols_types.items()
587    }
588    existing_cols_indices = self.get_pipe_columns_indices(pipe, debug=debug)
589    existing_ix_names = set()
590    existing_primary_keys = []
591    existing_clustered_primary_keys = []
592    for col, col_indices in existing_cols_indices.items():
593        for col_ix_doc in col_indices:
594            existing_ix_names.add(col_ix_doc.get('name', '').lower())
595            if col_ix_doc.get('type', None) == 'PRIMARY KEY':
596                existing_primary_keys.append(col.lower())
597                if col_ix_doc.get('clustered', True):
598                    existing_clustered_primary_keys.append(col.lower())
599
600    _datetime = pipe.get_columns('datetime', error=False)
601    _datetime_name = (
602        sql_item_name(_datetime, self.flavor, None)
603        if _datetime is not None else None
604    )
605    _datetime_index_name = (
606        sql_item_name(index_names['datetime'], flavor=self.flavor, schema=None)
607        if index_names.get('datetime', None)
608        else None
609    )
610    _id = pipe.get_columns('id', error=False)
611    _id_name = (
612        sql_item_name(_id, self.flavor, None)
613        if _id is not None
614        else None
615    )
616    primary_key = pipe.columns.get('primary', None)
617    primary_key_name = (
618        sql_item_name(primary_key, flavor=self.flavor, schema=None)
619        if primary_key
620        else None
621    )
622    autoincrement = (
623        pipe.parameters.get('autoincrement', False)
624        or (
625            primary_key is not None
626            and primary_key not in existing_cols_pd_types
627        )
628    )
629    primary_key_db_type = (
630        get_db_type_from_pd_type(pipe.dtypes.get(primary_key, 'int') or 'int', self.flavor)
631        if primary_key
632        else None
633    )
634    primary_key_constraint_name = (
635        sql_item_name(f'PK_{pipe.target}', self.flavor, None)
636        if primary_key is not None
637        else None
638    )
639    primary_key_clustered = "CLUSTERED" if _datetime is None else "NONCLUSTERED"
640    datetime_clustered = (
641        "CLUSTERED"
642        if not existing_clustered_primary_keys and _datetime is not None
643        else "NONCLUSTERED"
644    )
645    include_columns_str = "\n    ,".join(
646        [
647            sql_item_name(col, flavor=self.flavor) for col in existing_cols_types
648            if col != _datetime
649        ]
650    ).rstrip(',')
651    include_clause = (
652        (
653            f"\nINCLUDE (\n    {include_columns_str}\n)"
654        )
655        if datetime_clustered == 'NONCLUSTERED'
656        else ''
657    )
658
659    _id_index_name = (
660        sql_item_name(index_names['id'], self.flavor, None)
661        if index_names.get('id', None)
662        else None
663    )
664    _pipe_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
665    _create_space_partition = get_config('system', 'experimental', 'space')
666
667    ### create datetime index
668    dt_query = None
669    if _datetime is not None:
670        if (
671            self.flavor in ('timescaledb', 'timescaledb-ha')
672            and pipe.parameters.get('hypertable', True)
673        ):
674            _id_count = (
675                get_distinct_col_count(_id, f"SELECT {_id_name} FROM {_pipe_name}", self)
676                if (_id is not None and _create_space_partition) else None
677            )
678
679            chunk_interval = pipe.get_chunk_interval(debug=debug)
680            chunk_interval_minutes = (
681                chunk_interval
682                if isinstance(chunk_interval, int)
683                else int(chunk_interval.total_seconds() / 60)
684            )
685            chunk_time_interval = (
686                f"INTERVAL '{chunk_interval_minutes} MINUTES'"
687                if isinstance(chunk_interval, timedelta)
688                else f'{chunk_interval_minutes}'
689            )
690
691            dt_query = (
692                f"SELECT public.create_hypertable('{_pipe_name}', " +
693                f"'{_datetime}', "
694                + (
695                    f"'{_id}', {_id_count}, " if (_id is not None and _create_space_partition)
696                    else ''
697                )
698                + f'chunk_time_interval => {chunk_time_interval}, '
699                + 'if_not_exists => true, '
700                + "migrate_data => true);"
701            )
702        elif _datetime_index_name and _datetime != primary_key:
703            if self.flavor == 'mssql':
704                dt_query = (
705                    f"CREATE {datetime_clustered} INDEX {_datetime_index_name} "
706                    f"\nON {_pipe_name} ({_datetime_name}){include_clause}"
707                )
708            else:
709                dt_query = (
710                    f"CREATE INDEX {_datetime_index_name} "
711                    + f"ON {_pipe_name} ({_datetime_name})"
712                )
713
714    if dt_query:
715        index_queries[_datetime] = [dt_query]
716
717    primary_queries = []
718    if (
719        primary_key is not None
720        and primary_key.lower() not in existing_primary_keys
721        and not static
722    ):
723        if autoincrement and primary_key not in existing_cols_pd_types:
724            autoincrement_str = AUTO_INCREMENT_COLUMN_FLAVORS.get(
725                self.flavor,
726                AUTO_INCREMENT_COLUMN_FLAVORS['default']
727            )
728            primary_queries.extend([
729                (
730                    f"ALTER TABLE {_pipe_name}\n"
731                    f"ADD {primary_key_name} {primary_key_db_type} {autoincrement_str}"
732                ),
733            ])
734        elif not autoincrement and primary_key in existing_cols_pd_types:
735            if self.flavor in ('sqlite', 'geopackage'):
736                new_table_name = sql_item_name(
737                    f'_new_{pipe.target}',
738                    self.flavor,
739                    self.get_pipe_schema(pipe)
740                )
741                select_cols_str = ', '.join(
742                    [
743                        sql_item_name(col, self.flavor, None)
744                        for col in existing_cols_types
745                    ]
746                )
747                primary_queries.extend(
748                    get_create_table_queries(
749                        existing_cols_pd_types,
750                        f'_new_{pipe.target}',
751                        self.flavor,
752                        schema=self.get_pipe_schema(pipe),
753                        primary_key=primary_key,
754                    ) + [
755                        (
756                            f"INSERT INTO {new_table_name} ({select_cols_str})\n"
757                            f"SELECT {select_cols_str}\nFROM {_pipe_name}"
758                        ),
759                        f"DROP TABLE {_pipe_name}",
760                    ] + get_rename_table_queries(
761                        f'_new_{pipe.target}',
762                        pipe.target,
763                        self.flavor,
764                        schema=self.get_pipe_schema(pipe),
765                    )
766                )
767            elif self.flavor == 'oracle':
768                primary_queries.extend([
769                    (
770                        f"ALTER TABLE {_pipe_name}\n"
771                        f"MODIFY {primary_key_name} NOT NULL"
772                    ),
773                    (
774                        f"ALTER TABLE {_pipe_name}\n"
775                        f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY ({primary_key_name})"
776                    )
777                ])
778            elif self.flavor in ('mysql', 'mariadb'):
779                primary_queries.extend([
780                    (
781                        f"ALTER TABLE {_pipe_name}\n"
782                        f"MODIFY {primary_key_name} {primary_key_db_type} NOT NULL"
783                    ),
784                    (
785                        f"ALTER TABLE {_pipe_name}\n"
786                        f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY ({primary_key_name})"
787                    )
788                ])
789            elif self.flavor in ('timescaledb', 'timescaledb-ha'):
790                primary_queries.extend([
791                    (
792                        f"ALTER TABLE {_pipe_name}\n"
793                        f"ALTER COLUMN {primary_key_name} SET NOT NULL"
794                    ),
795                    (
796                        f"ALTER TABLE {_pipe_name}\n"
797                        f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY (" + (
798                            f"{_datetime_name}, " if _datetime_name else ""
799                        ) + f"{primary_key_name})"
800                    ),
801                ])
802            elif self.flavor in ('citus', 'postgresql', 'duckdb', 'postgis'):
803                primary_queries.extend([
804                    (
805                        f"ALTER TABLE {_pipe_name}\n"
806                        f"ALTER COLUMN {primary_key_name} SET NOT NULL"
807                    ),
808                    (
809                        f"ALTER TABLE {_pipe_name}\n"
810                        f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY ({primary_key_name})"
811                    ),
812                ])
813            else:
814                primary_queries.extend([
815                    (
816                        f"ALTER TABLE {_pipe_name}\n"
817                        f"ALTER COLUMN {primary_key_name} {primary_key_db_type} NOT NULL"
818                    ),
819                    (
820                        f"ALTER TABLE {_pipe_name}\n"
821                        f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY {primary_key_clustered} ({primary_key_name})"
822                    ),
823                ])
824        index_queries[primary_key] = primary_queries
825
826    ### create id index
827    if _id_name is not None:
828        if self.flavor in ('timescaledb', 'timescaledb-ha'):
829            ### Already created indices via create_hypertable.
830            id_query = (
831                None if (_id is not None and _create_space_partition)
832                else (
833                    f"CREATE INDEX IF NOT EXISTS {_id_index_name} ON {_pipe_name} ({_id_name})"
834                    if _id is not None
835                    else None
836                )
837            )
838            pass
839        else: ### mssql, sqlite, etc.
840            id_query = (
841                None
842                if _is_non_indexable_col(_id, existing_cols_types, self.flavor)
843                else f"CREATE INDEX {_id_index_name} ON {_pipe_name} ({_id_name})"
844            )
845
846        if id_query is not None:
847            index_queries[_id] = id_query if isinstance(id_query, list) else [id_query]
848
849    ### Create indices for other labels in `pipe.columns`.
850    other_index_names = {
851        ix_key: ix_unquoted
852        for ix_key, ix_unquoted in index_names.items()
853        if (
854            ix_key not in ('datetime', 'id', 'primary')
855            and ix_unquoted.lower() not in existing_ix_names
856        )
857    }
858    for ix_key, ix_unquoted in other_index_names.items():
859        ix_name = sql_item_name(ix_unquoted, self.flavor, None)
860        cols = indices[ix_key]
861        if not isinstance(cols, (list, tuple)):
862            cols = [cols]
863        if ix_key == 'unique' and upsert:
864            continue
865        if self.flavor in ('mysql', 'mariadb', 'mssql'):
866            cols = [
867                col for col in cols
868                if col and not _is_non_indexable_col(
869                    col, existing_cols_types, self.flavor
870                )
871            ]
872        cols_names = [sql_item_name(col, self.flavor, None) for col in cols if col]
873        if not cols_names:
874            continue
875
876        cols_names_str = ", ".join(cols_names)
877        index_query_params_clause = f" ({cols_names_str})"
878        if self.flavor in ('postgis', 'timescaledb-ha'):
879            for col in cols:
880                col_typ = existing_cols_pd_types.get(cols[0], 'object')
881                if col_typ != 'object' and are_dtypes_equal(col_typ, 'geometry'):
882                    index_query_params_clause = f" USING GIST ({cols_names_str})"
883                    break
884
885        index_queries[ix_key] = [
886            f"CREATE INDEX {ix_name} ON {_pipe_name}{index_query_params_clause}"
887        ]
888
889    indices_cols_str = ', '.join(
890        list({
891            sql_item_name(ix, self.flavor)
892            for ix_key, ix in pipe.columns.items()
893            if ix and ix in existing_cols_types
894        })
895    )
896    coalesce_indices_cols_str = ', '.join(
897        [
898            (
899                (
900                    "COALESCE("
901                    + sql_item_name(ix, self.flavor)
902                    + ", "
903                    + get_null_replacement(existing_cols_types[ix], self.flavor)
904                    + ") "
905                )
906                if ix_key != 'datetime' and null_indices
907                else sql_item_name(ix, self.flavor)
908            )
909            for ix_key, ix in pipe.columns.items()
910            if ix and ix in existing_cols_types
911        ]
912    )
913    unique_index_name = sql_item_name(unique_index_name_unquoted, self.flavor)
914    constraint_name_unquoted = unique_index_name_unquoted.replace('IX_', 'UQ_')
915    constraint_name = sql_item_name(constraint_name_unquoted, self.flavor)
916    unique_target_exists = (
917        unique_index_name_unquoted.lower() in existing_ix_names
918        or (
919            self.flavor in _POSTGRES_UNIQUE_CONSTRAINT_FLAVORS
920            and constraint_name_unquoted.lower() in existing_ix_names
921        )
922    )
923    add_constraint_query = (
924        f"ALTER TABLE {_pipe_name} ADD CONSTRAINT {constraint_name} UNIQUE ({indices_cols_str})"
925    )
926    unique_index_cols_str = (
927        indices_cols_str
928        if self.flavor not in COALESCE_UNIQUE_INDEX_FLAVORS or not null_indices
929        else coalesce_indices_cols_str
930    )
931    create_unique_index_query = (
932        f"CREATE UNIQUE INDEX {unique_index_name} ON {_pipe_name} ({unique_index_cols_str})"
933    )
934    constraint_queries = (
935        [add_constraint_query]
936        if self.flavor in _POSTGRES_UNIQUE_CONSTRAINT_FLAVORS
937        else [create_unique_index_query]
938    )
939    if self.flavor not in (
940        'sqlite', 'geopackage', *_POSTGRES_UNIQUE_CONSTRAINT_FLAVORS
941    ):
942        constraint_queries.append(add_constraint_query)
943    if upsert and indices_cols_str and not unique_target_exists:
944        index_queries[unique_index_name] = constraint_queries
945        ### Remove regular indices that cover the same single column as the unique index.
946        ### Some flavors (e.g. Oracle) reject two indices on the same column combination.
947        if unique_index_cols_str == _id_name:
948            index_queries.pop(_id, None)
949        if unique_index_cols_str == _datetime_name:
950            index_queries.pop(_datetime, None)
951    return index_queries

Return a dictionary mapping columns to a CREATE INDEX or equivalent query.

Parameters
  • pipe (mrsm.Pipe): The pipe to which the queries will correspond.
Returns
  • A dictionary of index names mapping to lists of queries.
def get_drop_index_queries( self, pipe: meerschaum.Pipe, debug: bool = False) -> Dict[str, List[str]]:
 954def get_drop_index_queries(
 955    self,
 956    pipe: mrsm.Pipe,
 957    debug: bool = False,
 958) -> Dict[str, List[str]]:
 959    """
 960    Return a dictionary mapping columns to a `DROP INDEX` or equivalent query.
 961
 962    Parameters
 963    ----------
 964    pipe: mrsm.Pipe
 965        The pipe to which the queries will correspond.
 966
 967    Returns
 968    -------
 969    A dictionary of column names mapping to lists of queries.
 970    """
 971    ### NOTE: Due to breaking changes within DuckDB, indices must be skipped.
 972    if self.flavor == 'duckdb':
 973        return {}
 974    if not pipe.exists(debug=debug):
 975        return {}
 976
 977    from collections import defaultdict
 978    from meerschaum.utils.sql import (
 979        sql_item_name,
 980        table_exists,
 981        hypertable_queries,
 982        DROP_INDEX_IF_EXISTS_FLAVORS,
 983    )
 984    drop_queries = defaultdict(lambda: [])
 985    schema = self.get_pipe_schema(pipe)
 986    index_schema = schema if self.flavor != 'mssql' else None
 987    indices = {
 988        ix_key: ix
 989        for ix_key, ix in pipe.get_indices().items()
 990    }
 991    cols_indices = pipe.get_columns_indices(debug=debug)
 992    existing_indices = set()
 993    clustered_ix = None
 994    for col, ix_metas in cols_indices.items():
 995        for ix_meta in ix_metas:
 996            ix_name = ix_meta.get('name', None)
 997            if ix_meta.get('clustered', False):
 998                clustered_ix = ix_name
 999            existing_indices.add(ix_name.lower())
1000    pipe_name = sql_item_name(pipe.target, self.flavor, schema)
1001    pipe_name_no_schema = sql_item_name(pipe.target, self.flavor, None)
1002    upsert = pipe.upsert
1003
1004    if self.flavor not in hypertable_queries:
1005        is_hypertable = False
1006    else:
1007        is_hypertable_query = hypertable_queries[self.flavor].format(table_name=pipe_name)
1008        is_hypertable = self.value(is_hypertable_query, silent=True, debug=debug) is not None
1009
1010    if_exists_str = "IF EXISTS " if self.flavor in DROP_INDEX_IF_EXISTS_FLAVORS else ""
1011    if is_hypertable:
1012        nuke_queries = []
1013        temp_table = '_' + pipe.target + '_temp_migration'
1014        temp_table_name = sql_item_name(temp_table, self.flavor, self.get_pipe_schema(pipe))
1015
1016        if table_exists(temp_table, self, schema=self.get_pipe_schema(pipe), debug=debug):
1017            nuke_queries.append(f"DROP TABLE {if_exists_str} {temp_table_name}")
1018        nuke_queries += [
1019            f"SELECT * INTO {temp_table_name} FROM {pipe_name}",
1020            f"DROP TABLE {if_exists_str}{pipe_name}",
1021            f"ALTER TABLE {temp_table_name} RENAME TO {pipe_name_no_schema}",
1022        ]
1023        nuke_ix_keys = ('datetime', 'id')
1024        nuked = False
1025        for ix_key in nuke_ix_keys:
1026            if ix_key in indices and not nuked:
1027                drop_queries[ix_key].extend(nuke_queries)
1028                nuked = True
1029
1030    for ix_key, ix_unquoted in indices.items():
1031        if ix_key in drop_queries:
1032            continue
1033        is_unique_upsert = ix_key == 'unique' and upsert
1034        constraint_name_unquoted = ix_unquoted.replace('IX_', 'UQ_')
1035        index_exists = ix_unquoted.lower() in existing_indices
1036        ### Other flavors (e.g. Oracle) may back the constraint with the `IX_` index itself,
1037        ### so the `UQ_` name is only listed as an index on PostgreSQL.
1038        constraint_exists = (
1039            constraint_name_unquoted.lower() in existing_indices
1040            if self.flavor in _POSTGRES_UNIQUE_CONSTRAINT_FLAVORS
1041            else index_exists
1042        )
1043        if not index_exists and not (is_unique_upsert and constraint_exists):
1044            continue
1045
1046        if (
1047            is_unique_upsert
1048            and self.flavor not in ('sqlite', 'geopackage')
1049            and not is_hypertable
1050            and constraint_exists
1051        ):
1052            constraint_name = sql_item_name(constraint_name_unquoted, self.flavor)
1053            constraint_or_index = (
1054                "CONSTRAINT"
1055                if self.flavor not in ('mysql', 'mariadb')
1056                else 'INDEX'
1057            )
1058            drop_queries[ix_key].append(
1059                f"ALTER TABLE {pipe_name}\n"
1060                f"DROP {constraint_or_index} {constraint_name}"
1061            )
1062
1063        if not index_exists:
1064            continue
1065
1066        query = (
1067            (
1068                f"ALTER TABLE {pipe_name}\n"
1069                if self.flavor in ('mysql', 'mariadb')
1070                else ''
1071            )
1072            + f"DROP INDEX {if_exists_str}"
1073            + sql_item_name(ix_unquoted, self.flavor, index_schema)
1074        )
1075        if self.flavor == 'mssql':
1076            query += f"\nON {pipe_name}"
1077            if ix_unquoted == clustered_ix:
1078                query += "\nWITH (ONLINE = ON, MAXDOP = 4)"
1079        drop_queries[ix_key].append(query)
1080
1081
1082    return drop_queries

Return a dictionary mapping columns to a DROP INDEX or equivalent query.

Parameters
  • pipe (mrsm.Pipe): The pipe to which the queries will correspond.
Returns
  • A dictionary of column names mapping to lists of queries.
def get_add_columns_queries( self, pipe: meerschaum.Pipe, df: 'Union[pd.DataFrame, Dict[str, str]]', _is_db_types: bool = False, debug: bool = False) -> List[str]:
3481def get_add_columns_queries(
3482    self,
3483    pipe: mrsm.Pipe,
3484    df: Union[pd.DataFrame, Dict[str, str]],
3485    _is_db_types: bool = False,
3486    debug: bool = False,
3487) -> List[str]:
3488    """
3489    Add new null columns of the correct type to a table from a dataframe.
3490
3491    Parameters
3492    ----------
3493    pipe: mrsm.Pipe
3494        The pipe to be altered.
3495
3496    df: Union[pd.DataFrame, Dict[str, str]]
3497        The pandas DataFrame which contains new columns.
3498        If a dictionary is provided, assume it maps columns to Pandas data types.
3499
3500    _is_db_types: bool, default False
3501        If `True`, assume `df` is a dictionary mapping columns to SQL native dtypes.
3502
3503    Returns
3504    -------
3505    A list of the `ALTER TABLE` SQL query or queries to be executed on the provided connector.
3506    """
3507    if not pipe.exists(debug=debug):
3508        return []
3509
3510    if pipe.parameters.get('static', False):
3511        return []
3512
3513    from decimal import Decimal
3514    import copy
3515    from meerschaum.utils.sql import (
3516        sql_item_name,
3517        SINGLE_ALTER_TABLE_FLAVORS,
3518        get_table_cols_types,
3519    )
3520    from meerschaum.utils.dtypes.sql import (
3521        get_pd_type_from_db_type,
3522        get_db_type_from_pd_type,
3523    )
3524    from meerschaum.utils.misc import flatten_list
3525    is_dask = 'dask' in df.__module__ if not isinstance(df, dict) else False
3526    if is_dask:
3527        df = df.partitions[0].compute()
3528    df_cols_types = (
3529        {
3530            col: str(typ)
3531            for col, typ in df.dtypes.items()
3532        }
3533        if not isinstance(df, dict)
3534        else copy.deepcopy(df)
3535    )
3536    if not isinstance(df, dict) and len(df.index) > 0:
3537        for col, typ in list(df_cols_types.items()):
3538            if typ != 'object':
3539                continue
3540            val = df.iloc[0][col]
3541            if isinstance(val, (dict, list)):
3542                df_cols_types[col] = 'json'
3543            elif isinstance(val, Decimal):
3544                df_cols_types[col] = 'numeric'
3545            elif isinstance(val, str):
3546                df_cols_types[col] = 'str'
3547    db_cols_types = {
3548        col: get_pd_type_from_db_type(typ)
3549        for col, typ in get_table_cols_types(
3550            pipe.target,
3551            self,
3552            schema=self.get_pipe_schema(pipe),
3553            debug=debug,
3554        ).items()
3555    }
3556    new_cols = set(df_cols_types) - set(db_cols_types)
3557    if not new_cols:
3558        return []
3559
3560    new_cols_types = {
3561        col: get_db_type_from_pd_type(
3562            df_cols_types[col],
3563            self.flavor
3564        )
3565        for col in new_cols
3566        if col and df_cols_types.get(col, None)
3567    }
3568
3569    alter_table_query = "ALTER TABLE " + sql_item_name(
3570        pipe.target, self.flavor, self.get_pipe_schema(pipe)
3571    )
3572    queries = []
3573    for col, typ in new_cols_types.items():
3574        add_col_query = (
3575            "\nADD "
3576            + sql_item_name(col, self.flavor, None)
3577            + " " + typ + ","
3578        )
3579
3580        if self.flavor in SINGLE_ALTER_TABLE_FLAVORS:
3581            queries.append(alter_table_query + add_col_query[:-1])
3582        else:
3583            alter_table_query += add_col_query
3584
3585    ### For most flavors, only one query is required.
3586    ### This covers SQLite which requires one query per column.
3587    if not queries:
3588        queries.append(alter_table_query[:-1])
3589
3590    if self.flavor != 'duckdb':
3591        return queries
3592
3593    ### NOTE: For DuckDB, we must drop and rebuild the indices.
3594    drop_index_queries = list(flatten_list(
3595        [q for ix, q in self.get_drop_index_queries(pipe, debug=debug).items()]
3596    ))
3597    create_index_queries = list(flatten_list(
3598        [q for ix, q in self.get_create_index_queries(pipe, debug=debug).items()]
3599    ))
3600
3601    return drop_index_queries + queries + create_index_queries

Add new null columns of the correct type to a table from a dataframe.

Parameters
  • pipe (mrsm.Pipe): The pipe to be altered.
  • df (Union[pd.DataFrame, Dict[str, str]]): The pandas DataFrame which contains new columns. If a dictionary is provided, assume it maps columns to Pandas data types.
  • _is_db_types (bool, default False): If True, assume df is a dictionary mapping columns to SQL native dtypes.
Returns
  • A list of the ALTER TABLE SQL query or queries to be executed on the provided connector.
def get_alter_columns_queries( self, pipe: meerschaum.Pipe, df: 'Union[pd.DataFrame, Dict[str, str]]', debug: bool = False) -> List[str]:
3604def get_alter_columns_queries(
3605    self,
3606    pipe: mrsm.Pipe,
3607    df: Union[pd.DataFrame, Dict[str, str]],
3608    debug: bool = False,
3609) -> List[str]:
3610    """
3611    If we encounter a column of a different type, set the entire column to text.
3612    If the altered columns are numeric, alter to numeric instead.
3613
3614    Parameters
3615    ----------
3616    pipe: mrsm.Pipe
3617        The pipe to be altered.
3618
3619    df: Union[pd.DataFrame, Dict[str, str]]
3620        The pandas DataFrame which may contain altered columns.
3621        If a dict is provided, assume it maps columns to Pandas data types.
3622
3623    Returns
3624    -------
3625    A list of the `ALTER TABLE` SQL query or queries to be executed on the provided connector.
3626    """
3627    if not pipe.exists(debug=debug) or pipe.static:
3628        return []
3629
3630    from meerschaum.utils.sql import (
3631        sql_item_name,
3632        get_table_cols_types,
3633        DROP_IF_EXISTS_FLAVORS,
3634        SINGLE_ALTER_TABLE_FLAVORS,
3635    )
3636    from meerschaum.utils.dataframe import get_numeric_cols
3637    from meerschaum.utils.dtypes import are_dtypes_equal
3638    from meerschaum.utils.dtypes.sql import (
3639        get_pd_type_from_db_type,
3640        get_db_type_from_pd_type,
3641    )
3642    from meerschaum.utils.misc import flatten_list, generate_password, items_str
3643    target = pipe.target
3644    session_id = generate_password(3)
3645    numeric_cols = (
3646        get_numeric_cols(df)
3647        if not isinstance(df, dict)
3648        else [
3649            col
3650            for col, typ in df.items()
3651            if typ.startswith('numeric')
3652        ]
3653    )
3654    df_cols_types = (
3655        {
3656            col: str(typ)
3657            for col, typ in df.dtypes.items()
3658        }
3659        if not isinstance(df, dict)
3660        else df
3661    )
3662    db_cols_types = {
3663        col: get_pd_type_from_db_type(typ)
3664        for col, typ in get_table_cols_types(
3665            pipe.target,
3666            self,
3667            schema=self.get_pipe_schema(pipe),
3668            debug=debug,
3669        ).items()
3670    }
3671    pipe_dtypes = pipe.get_dtypes(debug=debug)
3672    pipe_bool_cols = [col for col, typ in pipe_dtypes.items() if are_dtypes_equal(str(typ), 'bool')]
3673    pd_db_df_aliases = {
3674        'int': 'bool',
3675        'float': 'bool',
3676        'numeric': 'bool',
3677        'guid': 'object',
3678    }
3679    if self.flavor == 'oracle':
3680        pd_db_df_aliases.update({
3681            'int': 'numeric',
3682            'date': 'datetime',
3683            'numeric': 'int',
3684        })
3685    elif self.flavor == 'geopackage':
3686        pd_db_df_aliases.update({
3687            'geometry': 'bytes',
3688            'bytes': 'geometry',
3689        })
3690
3691    altered_cols = {
3692        col: (db_cols_types.get(col, 'object'), typ)
3693        for col, typ in df_cols_types.items()
3694        if not are_dtypes_equal(typ, db_cols_types.get(col, 'object').lower())
3695        and not are_dtypes_equal(db_cols_types.get(col, 'object'), 'string')
3696    }
3697
3698    if debug and altered_cols:
3699        dprint("Columns to be altered:")
3700        mrsm.pprint(altered_cols)
3701
3702    ### NOTE: Special columns (numerics, bools, etc.) are captured and cached upon detection.
3703    new_special_cols = pipe._get_cached_value('new_special_cols', debug=debug) or {}
3704    new_special_db_cols_types = {
3705        col: (db_cols_types.get(col, 'object'), typ)
3706        for col, typ in new_special_cols.items()
3707    }
3708    if debug:
3709        dprint("Cached new special columns:")
3710        mrsm.pprint(new_special_cols)
3711        dprint("New special columns db types:")
3712        mrsm.pprint(new_special_db_cols_types)
3713
3714    altered_cols.update(new_special_db_cols_types)
3715
3716    ### NOTE: Sometimes bools are coerced into ints or floats.
3717    altered_cols_to_ignore = set()
3718    for col, (db_typ, df_typ) in altered_cols.items():
3719        for db_alias, df_alias in pd_db_df_aliases.items():
3720            if (
3721                db_alias in db_typ.lower()
3722                and df_alias in df_typ.lower()
3723                and col not in new_special_cols
3724            ):
3725                altered_cols_to_ignore.add(col)
3726
3727    ### Oracle's bool handling sometimes mixes NUMBER and INT.
3728    for bool_col in pipe_bool_cols:
3729        if bool_col not in altered_cols:
3730            continue
3731        db_is_bool_compatible = (
3732            are_dtypes_equal('int', altered_cols[bool_col][0])
3733            or are_dtypes_equal('float', altered_cols[bool_col][0])
3734            or are_dtypes_equal('numeric', altered_cols[bool_col][0])
3735            or are_dtypes_equal('bool', altered_cols[bool_col][0])
3736        )
3737        df_is_bool_compatible = (
3738            are_dtypes_equal('int', altered_cols[bool_col][1])
3739            or are_dtypes_equal('float', altered_cols[bool_col][1])
3740            or are_dtypes_equal('numeric', altered_cols[bool_col][1])
3741            or are_dtypes_equal('bool', altered_cols[bool_col][1])
3742        )
3743        if db_is_bool_compatible and df_is_bool_compatible:
3744            altered_cols_to_ignore.add(bool_col)
3745
3746    if debug and altered_cols_to_ignore:
3747        dprint("Ignoring the following altered columns (false positives).")
3748        mrsm.pprint(altered_cols_to_ignore)
3749
3750    for col in altered_cols_to_ignore:
3751        _ = altered_cols.pop(col, None)
3752
3753    if not altered_cols:
3754        return []
3755
3756    if numeric_cols:
3757        explicit_pipe_dtypes = pipe.get_dtypes(infer=False, debug=debug)
3758        explicit_pipe_dtypes.update({col: 'numeric' for col in numeric_cols})
3759        pipe.dtypes = explicit_pipe_dtypes
3760        if not pipe.temporary:
3761            edit_success, edit_msg = pipe.edit(debug=debug)
3762            if not edit_success:
3763                warn(
3764                    f"Failed to update dtypes for numeric columns {items_str(numeric_cols)}:\n"
3765                    + f"{edit_msg}"
3766                )
3767    else:
3768        numeric_cols.extend([col for col, typ in pipe_dtypes.items() if typ.startswith('numeric')])
3769
3770    numeric_type = get_db_type_from_pd_type('numeric', self.flavor, as_sqlalchemy=False)
3771    text_type = get_db_type_from_pd_type('str', self.flavor, as_sqlalchemy=False)
3772    altered_cols_types = {
3773        col: (
3774            numeric_type
3775            if col in numeric_cols
3776            else text_type
3777        )
3778        for col, (db_typ, typ) in altered_cols.items()
3779    }
3780
3781    if self.flavor in ('sqlite', 'geopackage'):
3782        temp_table_name = '-' + session_id + '_' + target
3783        rename_query = (
3784            "ALTER TABLE "
3785            + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3786            + " RENAME TO "
3787            + sql_item_name(temp_table_name, self.flavor, None)
3788        )
3789        create_query = (
3790            "CREATE TABLE "
3791            + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3792            + " (\n"
3793        )
3794        for col_name, col_typ in db_cols_types.items():
3795            create_query += (
3796                sql_item_name(col_name, self.flavor, None)
3797                + " "
3798                + (
3799                    col_typ
3800                    if col_name not in altered_cols
3801                    else altered_cols_types[col_name]
3802                )
3803                + ",\n"
3804            )
3805        create_query = create_query[:-2] + "\n)"
3806
3807        insert_query = (
3808            "INSERT INTO "
3809            + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3810            + ' ('
3811            + ', '.join([
3812                sql_item_name(col_name, self.flavor, None)
3813                for col_name in db_cols_types
3814            ])
3815            + ')'
3816            + "\nSELECT\n"
3817        )
3818        for col_name in db_cols_types:
3819            new_col_str = (
3820                sql_item_name(col_name, self.flavor, None)
3821                if col_name not in altered_cols
3822                else (
3823                    "CAST("
3824                    + sql_item_name(col_name, self.flavor, None)
3825                    + " AS "
3826                    + altered_cols_types[col_name]
3827                    + ")"
3828                )
3829            )
3830            insert_query += new_col_str + ",\n"
3831
3832        insert_query = insert_query[:-2] + (
3833            f"\nFROM {sql_item_name(temp_table_name, self.flavor, self.get_pipe_schema(pipe))}"
3834        )
3835
3836        if_exists_str = "IF EXISTS" if self.flavor in DROP_IF_EXISTS_FLAVORS else ""
3837
3838        drop_query = f"DROP TABLE {if_exists_str}" + sql_item_name(
3839            temp_table_name, self.flavor, self.get_pipe_schema(pipe)
3840        )
3841        return [
3842            rename_query,
3843            create_query,
3844            insert_query,
3845            drop_query,
3846        ]
3847
3848    queries = []
3849    if self.flavor == 'oracle':
3850        for col, typ in altered_cols_types.items():
3851            add_query = (
3852                "ALTER TABLE "
3853                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3854                + "\nADD " + sql_item_name(col + '_temp', self.flavor, None)
3855                + " " + typ
3856            )
3857            queries.append(add_query)
3858
3859        for col, typ in altered_cols_types.items():
3860            populate_temp_query = (
3861                "UPDATE "
3862                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3863                + "\nSET " + sql_item_name(col + '_temp', self.flavor, None)
3864                + ' = ' + sql_item_name(col, self.flavor, None)
3865            )
3866            queries.append(populate_temp_query)
3867
3868        for col, typ in altered_cols_types.items():
3869            set_old_cols_to_null_query = (
3870                "UPDATE "
3871                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3872                + "\nSET " + sql_item_name(col, self.flavor, None)
3873                + ' = NULL'
3874            )
3875            queries.append(set_old_cols_to_null_query)
3876
3877        for col, typ in altered_cols_types.items():
3878            alter_type_query = (
3879                "ALTER TABLE "
3880                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3881                + "\nMODIFY " + sql_item_name(col, self.flavor, None) + ' '
3882                + typ
3883            )
3884            queries.append(alter_type_query)
3885
3886        for col, typ in altered_cols_types.items():
3887            set_old_to_temp_query = (
3888                "UPDATE "
3889                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3890                + "\nSET " + sql_item_name(col, self.flavor, None)
3891                + ' = ' + sql_item_name(col + '_temp', self.flavor, None)
3892            )
3893            queries.append(set_old_to_temp_query)
3894
3895        for col, typ in altered_cols_types.items():
3896            drop_temp_query = (
3897                "ALTER TABLE "
3898                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3899                + "\nDROP COLUMN " + sql_item_name(col + '_temp', self.flavor, None)
3900            )
3901            queries.append(drop_temp_query)
3902
3903        return queries
3904
3905    query = "ALTER TABLE " + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3906    for col, typ in altered_cols_types.items():
3907        alter_col_prefix = (
3908            'ALTER' if self.flavor not in ('mysql', 'mariadb', 'oracle')
3909            else 'MODIFY'
3910        )
3911        type_prefix = (
3912            '' if self.flavor in ('mssql', 'mariadb', 'mysql')
3913            else 'TYPE '
3914        )
3915        column_str = 'COLUMN' if self.flavor != 'oracle' else ''
3916        query_suffix = (
3917            f"\n{alter_col_prefix} {column_str} "
3918            + sql_item_name(col, self.flavor, None)
3919            + " " + type_prefix + typ + ","
3920        )
3921        if self.flavor not in SINGLE_ALTER_TABLE_FLAVORS:
3922            query += query_suffix
3923        else:
3924            queries.append(query + query_suffix[:-1])
3925
3926    if self.flavor not in SINGLE_ALTER_TABLE_FLAVORS:
3927        queries.append(query[:-1])
3928
3929    if self.flavor != 'duckdb':
3930        return queries
3931
3932    drop_index_queries = list(flatten_list(
3933        [q for ix, q in self.get_drop_index_queries(pipe, debug=debug).items()]
3934    ))
3935    create_index_queries = list(flatten_list(
3936        [q for ix, q in self.get_create_index_queries(pipe, debug=debug).items()]
3937    ))
3938
3939    return drop_index_queries + queries + create_index_queries

If we encounter a column of a different type, set the entire column to text. If the altered columns are numeric, alter to numeric instead.

Parameters
  • pipe (mrsm.Pipe): The pipe to be altered.
  • df (Union[pd.DataFrame, Dict[str, str]]): The pandas DataFrame which may contain altered columns. If a dict is provided, assume it maps columns to Pandas data types.
Returns
  • A list of the ALTER TABLE SQL query or queries to be executed on the provided connector.
def delete_pipe( self, pipe: meerschaum.Pipe, debug: bool = False) -> Tuple[bool, str]:
1085def delete_pipe(
1086    self,
1087    pipe: mrsm.Pipe,
1088    debug: bool = False,
1089) -> SuccessTuple:
1090    """
1091    Delete a Pipe's registration.
1092    """
1093    from meerschaum.utils.packages import attempt_import
1094    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
1095
1096    if not pipe.id:
1097        return False, f"{pipe} is not registered."
1098
1099    ### ensure pipes table exists
1100    from meerschaum.connectors.sql.tables import get_tables
1101    pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes']
1102
1103    q = sqlalchemy.delete(pipes_tbl).where(pipes_tbl.c.pipe_id == pipe.id)
1104    if not self.exec(q, debug=debug):
1105        return False, f"Failed to delete registration for {pipe}."
1106
1107    return True, "Success"

Delete a Pipe's registration.

def get_pipe_data( self, pipe: meerschaum.Pipe, select_columns: Optional[List[str]] = None, omit_columns: Optional[List[str]] = None, begin: Union[datetime.datetime, str, NoneType] = None, end: Union[datetime.datetime, str, NoneType] = None, params: Optional[Dict[str, Any]] = None, order: str = 'asc', limit: Optional[int] = None, begin_add_minutes: int = 0, end_add_minutes: int = 0, chunksize: Optional[int] = -1, as_iterator: bool = False, as_polars: bool = False, debug: bool = False, **kw: Any) -> 'Union[pd.DataFrame, None]':
1110def get_pipe_data(
1111    self,
1112    pipe: mrsm.Pipe,
1113    select_columns: Optional[List[str]] = None,
1114    omit_columns: Optional[List[str]] = None,
1115    begin: Union[datetime, str, None] = None,
1116    end: Union[datetime, str, None] = None,
1117    params: Optional[Dict[str, Any]] = None,
1118    order: str = 'asc',
1119    limit: Optional[int] = None,
1120    begin_add_minutes: int = 0,
1121    end_add_minutes: int = 0,
1122    chunksize: Optional[int] = -1,
1123    as_iterator: bool = False,
1124    as_polars: bool = False,
1125    debug: bool = False,
1126    **kw: Any
1127) -> Union[pd.DataFrame, None]:
1128    """
1129    Access a pipe's data from the SQL instance.
1130
1131    Parameters
1132    ----------
1133    pipe: mrsm.Pipe:
1134        The pipe to get data from.
1135
1136    select_columns: Optional[List[str]], default None
1137        If provided, only select these given columns.
1138        Otherwise select all available columns (i.e. `SELECT *`).
1139
1140    omit_columns: Optional[List[str]], default None
1141        If provided, remove these columns from the selection.
1142
1143    begin: Union[datetime, str, None], default None
1144        If provided, get rows newer than or equal to this value.
1145
1146    end: Union[datetime, str, None], default None
1147        If provided, get rows older than or equal to this value.
1148
1149    params: Optional[Dict[str, Any]], default None
1150        Additional parameters to filter by.
1151        See `meerschaum.connectors.sql.build_where`.
1152
1153    order: Optional[str], default 'asc'
1154        The selection order for all of the indices in the query.
1155        If `None`, omit the `ORDER BY` clause.
1156
1157    limit: Optional[int], default None
1158        If specified, limit the number of rows retrieved to this value.
1159
1160    begin_add_minutes: int, default 0
1161        The number of minutes to add to the `begin` datetime (i.e. `DATEADD`).
1162
1163    end_add_minutes: int, default 0
1164        The number of minutes to add to the `end` datetime (i.e. `DATEADD`).
1165
1166    chunksize: Optional[int], default -1
1167        The size of dataframe chunks to load into memory.
1168
1169    as_iterator: bool, default False
1170        If `True`, return the chunks iterator directly.
1171
1172    as_polars: bool, default False
1173        If `True`, use an Arrow-native driver when available and return a Polars DataFrame.
1174
1175    debug: bool, default False
1176        Verbosity toggle.
1177
1178    Returns
1179    -------
1180    A Pandas DataFrame, or a Polars DataFrame when an Arrow-native read is available.
1181
1182    """
1183    from meerschaum.utils.packages import import_pandas
1184    from meerschaum.utils.dtypes import to_pandas_dtype, are_dtypes_equal
1185    from meerschaum.utils.dtypes.sql import get_pd_type_from_db_type
1186    pd = import_pandas()
1187    is_dask = 'dask' in pd.__name__
1188
1189    cols_types = pipe.get_columns_types(debug=debug) if pipe.enforce else {}
1190    pipe_dtypes = pipe.get_dtypes(infer=False, debug=debug) if pipe.enforce else {}
1191
1192    remote_pandas_types = {
1193        col: to_pandas_dtype(get_pd_type_from_db_type(typ))
1194        for col, typ in cols_types.items()
1195    }
1196    remote_dt_cols_types = {
1197        col: typ
1198        for col, typ in remote_pandas_types.items()
1199        if are_dtypes_equal(typ, 'datetime')
1200    }
1201    remote_dt_tz_aware_cols_types = {
1202        col: typ
1203        for col, typ in remote_dt_cols_types.items()
1204        if ',' in typ or typ == 'datetime'
1205    }
1206    remote_dt_tz_naive_cols_types = {
1207        col: typ
1208        for col, typ in remote_dt_cols_types.items()
1209        if col not in remote_dt_tz_aware_cols_types
1210    }
1211
1212    configured_pandas_types = {
1213        col: to_pandas_dtype(typ)
1214        for col, typ in pipe_dtypes.items()
1215    }
1216    configured_lower_precision_dt_cols_types = {
1217        col: typ
1218        for col, typ in pipe_dtypes.items()
1219        if (
1220            are_dtypes_equal('datetime', typ)
1221            and '[' in typ
1222            and 'ns' not in typ
1223        )
1224        
1225    }
1226
1227    dtypes = {
1228        **remote_pandas_types,
1229        **configured_pandas_types,
1230        **remote_dt_tz_aware_cols_types,
1231        **remote_dt_tz_naive_cols_types,
1232        **configured_lower_precision_dt_cols_types
1233    } if pipe.enforce else {}
1234
1235    existing_cols = cols_types.keys()
1236    select_columns = (
1237        [
1238            col
1239            for col in existing_cols
1240            if col not in (omit_columns or [])
1241        ]
1242        if not select_columns
1243        else [
1244            col
1245            for col in select_columns
1246            if col in existing_cols
1247            and col not in (omit_columns or [])
1248        ]
1249    ) if pipe.enforce else select_columns
1250
1251    if select_columns:
1252        dtypes = {col: typ for col, typ in dtypes.items() if col in select_columns}
1253
1254    dtypes = {
1255        col: typ
1256        for col, typ in dtypes.items()
1257        if col in (select_columns or [col]) and col not in (omit_columns or [])
1258    } if pipe.enforce else {}
1259
1260    if debug:
1261        dprint(f"[{self}] `read()` dtypes:")
1262        mrsm.pprint(dtypes)
1263
1264    query = self.get_pipe_data_query(
1265        pipe,
1266        select_columns=select_columns,
1267        omit_columns=omit_columns,
1268        begin=begin,
1269        end=end,
1270        params=params,
1271        order=order,
1272        limit=limit,
1273        begin_add_minutes=begin_add_minutes,
1274        end_add_minutes=end_add_minutes,
1275        debug=debug,
1276        **kw
1277    )
1278
1279    arrow_dtypes = {
1280        col: pipe_dtypes.get(col, get_pd_type_from_db_type(cols_types.get(col, '')))
1281        for col in dtypes
1282    }
1283    fallback_dtypes = {
1284        col: typ
1285        for col, typ in arrow_dtypes.items()
1286        if (
1287            str(typ).lower() in ('uuid', 'object', 'numeric')
1288            or str(typ).lower().startswith(('geometry', 'geography'))
1289        )
1290    }
1291    adbc_driver = {
1292        'sqlite': 'adbc_driver_sqlite',
1293        'postgresql': 'adbc_driver_postgresql',
1294        'postgis': 'adbc_driver_postgresql',
1295        'timescaledb': 'adbc_driver_postgresql',
1296        'timescaledb-ha': 'adbc_driver_postgresql',
1297        'citus': 'adbc_driver_postgresql',
1298        'cockroachdb': 'adbc_driver_postgresql',
1299    }.get(self.flavor, None)
1300    can_read_native = bool(
1301        as_polars
1302        and pipe.enforce
1303        and dtypes
1304        and not fallback_dtypes
1305        and not as_iterator
1306        and not is_dask
1307        and (
1308            self.flavor == 'duckdb'
1309            or (adbc_driver and not (self.flavor == 'sqlite' and self.database == ':memory:'))
1310        )
1311    )
1312    if can_read_native:
1313        from meerschaum.utils.packages import attempt_import
1314        polars = attempt_import('polars', lazy=False)
1315        try:
1316            if self.flavor == 'duckdb':
1317                with self.engine.connect() as connection:
1318                    return connection.connection.driver_connection.execute(query).pl()
1319            if attempt_import(
1320                adbc_driver,
1321                lazy=False,
1322                warn=False,
1323                install=False,
1324            ) is not None:
1325                import warnings
1326                adbc_uri = self.URI
1327                if adbc_driver == 'adbc_driver_postgresql':
1328                    adbc_uri = 'postgresql://' + adbc_uri.split('://', maxsplit=1)[-1]
1329                with warnings.catch_warnings():
1330                    warnings.filterwarnings(
1331                        'ignore',
1332                        message=r"Extension type 'arrow\.(json|opaque)' is not registered",
1333                    )
1334                    return polars.read_database_uri(query, adbc_uri, engine='adbc')
1335        except Exception as e:
1336            if debug:
1337                dprint(f"[{self}] Arrow-native read failed; falling back to Pandas:\n{e}")
1338
1339    read_kwargs = {}
1340    if is_dask:
1341        index_col = pipe.columns.get('datetime', None)
1342        read_kwargs['index_col'] = index_col
1343
1344    chunks = self.read(
1345        query,
1346        chunksize=chunksize,
1347        as_iterator=True,
1348        coerce_float=False,
1349        dtype=dtypes,
1350        debug=debug,
1351        **read_kwargs
1352    )
1353
1354    if as_iterator:
1355        return chunks
1356
1357    return pd.concat(chunks)

Access a pipe's data from the SQL instance.

Parameters
  • pipe (mrsm.Pipe:): The pipe to get data from.
  • select_columns (Optional[List[str]], default None): If provided, only select these given columns. Otherwise select all available columns (i.e. SELECT *).
  • omit_columns (Optional[List[str]], default None): If provided, remove these columns from the selection.
  • begin (Union[datetime, str, None], default None): If provided, get rows newer than or equal to this value.
  • end (Union[datetime, str, None], default None): If provided, get rows older than or equal to this value.
  • params (Optional[Dict[str, Any]], default None): Additional parameters to filter by. See meerschaum.connectors.sql.build_where.
  • order (Optional[str], default 'asc'): The selection order for all of the indices in the query. If None, omit the ORDER BY clause.
  • limit (Optional[int], default None): If specified, limit the number of rows retrieved to this value.
  • begin_add_minutes (int, default 0): The number of minutes to add to the begin datetime (i.e. DATEADD).
  • end_add_minutes (int, default 0): The number of minutes to add to the end datetime (i.e. DATEADD).
  • chunksize (Optional[int], default -1): The size of dataframe chunks to load into memory.
  • as_iterator (bool, default False): If True, return the chunks iterator directly.
  • as_polars (bool, default False): If True, use an Arrow-native driver when available and return a Polars DataFrame.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A Pandas DataFrame, or a Polars DataFrame when an Arrow-native read is available.
def get_pipe_docs( self, pipe: meerschaum.Pipe, select_columns: Optional[List[str]] = None, omit_columns: Optional[List[str]] = None, begin: Union[datetime.datetime, str, NoneType] = None, end: Union[datetime.datetime, str, NoneType] = None, params: Optional[Dict[str, Any]] = None, order: str = 'asc', limit: Optional[int] = None, debug: bool = False, **kw: Any) -> List[Dict[str, Any]]:
1360def get_pipe_docs(
1361    self,
1362    pipe: mrsm.Pipe,
1363    select_columns: Optional[List[str]] = None,
1364    omit_columns: Optional[List[str]] = None,
1365    begin: Union[datetime, str, None] = None,
1366    end: Union[datetime, str, None] = None,
1367    params: Optional[Dict[str, Any]] = None,
1368    order: str = 'asc',
1369    limit: Optional[int] = None,
1370    debug: bool = False,
1371    **kw: Any
1372) -> List[Dict[str, Any]]:
1373    """
1374    Return a pipe's data as a list of dictionaries, bypassing pandas overhead.
1375    """
1376    query = self.get_pipe_data_query(
1377        pipe=pipe,
1378        select_columns=select_columns,
1379        omit_columns=omit_columns,
1380        begin=begin,
1381        end=end,
1382        params=params,
1383        order=order,
1384        limit=limit,
1385        debug=debug,
1386    )
1387    if query is None:
1388        return []
1389    result = self.exec(query, silent=True, debug=debug)
1390    if result is None:
1391        return []
1392    return [dict(row) for row in result.mappings().fetchall()]

Return a pipe's data as a list of dictionaries, bypassing pandas overhead.

def get_pipe_data_query( self, pipe: meerschaum.Pipe, select_columns: Optional[List[str]] = None, omit_columns: Optional[List[str]] = None, begin: Union[datetime.datetime, int, str, NoneType] = None, end: Union[datetime.datetime, int, str, NoneType] = None, params: Optional[Dict[str, Any]] = None, order: Optional[str] = 'asc', sort_datetimes: bool = False, limit: Optional[int] = None, begin_add_minutes: int = 0, end_add_minutes: int = 0, replace_nulls: Optional[str] = None, skip_existing_cols_check: bool = False, debug: bool = False, **kw: Any) -> Optional[str]:
1395def get_pipe_data_query(
1396    self,
1397    pipe: mrsm.Pipe,
1398    select_columns: Optional[List[str]] = None,
1399    omit_columns: Optional[List[str]] = None,
1400    begin: Union[datetime, int, str, None] = None,
1401    end: Union[datetime, int, str, None] = None,
1402    params: Optional[Dict[str, Any]] = None,
1403    order: Optional[str] = 'asc',
1404    sort_datetimes: bool = False,
1405    limit: Optional[int] = None,
1406    begin_add_minutes: int = 0,
1407    end_add_minutes: int = 0,
1408    replace_nulls: Optional[str] = None,
1409    skip_existing_cols_check: bool = False,
1410    debug: bool = False,
1411    **kw: Any
1412) -> Union[str, None]:
1413    """
1414    Return the `SELECT` query for retrieving a pipe's data from its instance.
1415
1416    Parameters
1417    ----------
1418    pipe: mrsm.Pipe:
1419        The pipe to get data from.
1420
1421    select_columns: Optional[List[str]], default None
1422        If provided, only select these given columns.
1423        Otherwise select all available columns (i.e. `SELECT *`).
1424
1425    omit_columns: Optional[List[str]], default None
1426        If provided, remove these columns from the selection.
1427
1428    begin: Union[datetime, int, str, None], default None
1429        If provided, get rows newer than or equal to this value.
1430
1431    end: Union[datetime, str, None], default None
1432        If provided, get rows older than or equal to this value.
1433
1434    params: Optional[Dict[str, Any]], default None
1435        Additional parameters to filter by.
1436        See `meerschaum.connectors.sql.build_where`.
1437
1438    order: Optional[str], default None
1439        The selection order for all of the indices in the query.
1440        If `None`, omit the `ORDER BY` clause.
1441
1442    sort_datetimes: bool, default False
1443        Alias for `order='desc'`.
1444
1445    limit: Optional[int], default None
1446        If specified, limit the number of rows retrieved to this value.
1447
1448    begin_add_minutes: int, default 0
1449        The number of minutes to add to the `begin` datetime (i.e. `DATEADD`).
1450
1451    end_add_minutes: int, default 0
1452        The number of minutes to add to the `end` datetime (i.e. `DATEADD`).
1453
1454    chunksize: Optional[int], default -1
1455        The size of dataframe chunks to load into memory.
1456
1457    replace_nulls: Optional[str], default None
1458        If provided, replace null values with this value.
1459
1460    skip_existing_cols_check: bool, default False
1461        If `True`, do not verify that querying columns are actually on the table.
1462
1463    debug: bool, default False
1464        Verbosity toggle.
1465
1466    Returns
1467    -------
1468    A `SELECT` query to retrieve a pipe's data.
1469    """
1470    from meerschaum.utils.misc import items_str
1471    from meerschaum.utils.sql import sql_item_name, dateadd_str
1472    from meerschaum.utils.dtypes import coerce_timezone
1473    from meerschaum.utils.dtypes.sql import get_pd_type_from_db_type, get_db_type_from_pd_type
1474
1475    dt_col = pipe.columns.get('datetime', None)
1476    existing_cols = pipe.get_columns_types(debug=debug) if pipe.enforce else []
1477    skip_existing_cols_check = skip_existing_cols_check or not pipe.enforce
1478    dt_typ = get_pd_type_from_db_type(existing_cols[dt_col]) if dt_col in existing_cols else None
1479    dt_db_type = get_db_type_from_pd_type(dt_typ, self.flavor) if dt_typ else None
1480    select_columns = (
1481        [col for col in existing_cols]
1482        if not select_columns
1483        else [col for col in select_columns if skip_existing_cols_check or col in existing_cols]
1484    )
1485    if omit_columns:
1486        select_columns = [col for col in select_columns if col not in omit_columns]
1487
1488    if order is None and sort_datetimes:
1489        order = 'desc'
1490
1491    if begin == '':
1492        begin = pipe.get_sync_time(debug=debug)
1493        backtrack_interval = pipe.get_backtrack_interval(debug=debug)
1494        if begin is not None:
1495            begin -= backtrack_interval
1496
1497    begin, end = pipe.parse_date_bounds(begin, end)
1498    if isinstance(begin, datetime) and dt_typ:
1499        begin = coerce_timezone(begin, strip_utc=('utc' not in dt_typ.lower()))
1500    if isinstance(end, datetime) and dt_typ:
1501        end = coerce_timezone(end, strip_utc=('utc' not in dt_typ.lower()))
1502
1503    cols_names = [
1504        sql_item_name(col, self.flavor, None)
1505        for col in select_columns
1506    ]
1507    select_cols_str = (
1508        'SELECT\n    '
1509        + ',\n    '.join(
1510            [
1511                (
1512                    col_name
1513                    if not replace_nulls
1514                    else f"COALESCE(col_name, '{replace_nulls}') AS {col_name}"
1515                )
1516                for col_name in cols_names
1517            ]
1518        )
1519    ) if cols_names else 'SELECT *'
1520    pipe_table_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
1521    query = f"{select_cols_str}\nFROM {pipe_table_name}"
1522    where = ""
1523
1524    ### MariaDB 12.x optimizer bug: an ordered index scan with `LIMIT` over a `RANGE COLUMNS`-
1525    ### partitioned table that must read a non-indexed column returns zero rows (see the
1526    ### `get_sync_time` workaround in CLAUDE.md — same family, MariaDB-only). Wrapping the
1527    ### datetime column in the `ORDER BY` with a no-op `COALESCE(col, col)` forces a filesort
1528    ### over the fetched rows instead of the broken index walk, with identical ordering. Gated
1529    ### on `LIMIT` (the bug's trigger) so unlimited ordered reads keep the index-ordered scan.
1530    mariadb_partition_order_workaround = (
1531        self.flavor == 'mariadb'
1532        and isinstance(limit, int)
1533        and self._should_partition(pipe)
1534    )
1535
1536    if order is not None:
1537        default_order = 'asc'
1538        if order not in ('asc', 'desc'):
1539            warn(f"Ignoring unsupported order '{order}'. Falling back to '{default_order}'.")
1540            order = default_order
1541        order = order.upper()
1542
1543    if not pipe.columns.get('datetime', None):
1544        _dt = pipe.guess_datetime()
1545        dt = sql_item_name(_dt, self.flavor, None) if _dt else None
1546        is_guess = True
1547    else:
1548        _dt = pipe.get_columns('datetime')
1549        dt = sql_item_name(_dt, self.flavor, None)
1550        is_guess = False
1551
1552    quoted_indices = {
1553        key: sql_item_name(val, self.flavor, None)
1554        for key, val in pipe.columns.items()
1555        if val in existing_cols or skip_existing_cols_check
1556    }
1557
1558    if begin is not None or end is not None:
1559        if is_guess:
1560            if _dt is None:
1561                warn(
1562                    f"No datetime could be determined for {pipe}."
1563                    + "\n    Ignoring begin and end...",
1564                    stack=False,
1565                )
1566                begin, end = None, None
1567            else:
1568                warn(
1569                    f"A datetime wasn't specified for {pipe}.\n"
1570                    + f"    Using column \"{_dt}\" for datetime bounds...",
1571                    stack=False,
1572                )
1573
1574    is_dt_bound = False
1575    if begin is not None and (_dt in existing_cols or skip_existing_cols_check):
1576        begin_da = dateadd_str(
1577            flavor=self.flavor,
1578            datepart='minute',
1579            number=begin_add_minutes,
1580            begin=begin,
1581            db_type=dt_db_type,
1582        )
1583        where += f"\n    {dt} >= {begin_da}" + ("\n    AND\n    " if end is not None else "")
1584        is_dt_bound = True
1585
1586    if end is not None and (_dt in existing_cols or skip_existing_cols_check):
1587        if 'int' in str(type(end)).lower() and end == begin:
1588            end += 1
1589        end_da = dateadd_str(
1590            flavor=self.flavor,
1591            datepart='minute',
1592            number=end_add_minutes,
1593            begin=end,
1594            db_type=dt_db_type,
1595        )
1596        where += f"{dt} <  {end_da}"
1597        is_dt_bound = True
1598
1599    if params is not None:
1600        from meerschaum.utils.sql import build_where
1601        valid_params = {
1602            k: v
1603            for k, v in params.items()
1604            if k in existing_cols or skip_existing_cols_check
1605        }
1606        if valid_params:
1607            where += '    ' + build_where(valid_params, self).lstrip().replace(
1608                'WHERE', ('    AND' if is_dt_bound else "    ")
1609            )
1610
1611    if len(where) > 0:
1612        query += "\nWHERE " + where
1613
1614    if order is not None:
1615        ### Sort by indices, starting with datetime.
1616        order_by = ""
1617        if quoted_indices:
1618            order_by += "\nORDER BY "
1619            if _dt and (_dt in existing_cols or skip_existing_cols_check):
1620                dt_order_expr = (
1621                    f"COALESCE({dt}, {dt})"
1622                    if mariadb_partition_order_workaround
1623                    else dt
1624                )
1625                order_by += dt_order_expr + ' ' + order + ','
1626            for key, quoted_col_name in quoted_indices.items():
1627                if dt == quoted_col_name:
1628                    continue
1629                order_by += ' ' + quoted_col_name + ' ' + order + ','
1630            order_by = order_by[:-1]
1631
1632        query += order_by
1633
1634    if isinstance(limit, int):
1635        if self.flavor == 'mssql':
1636            query = f'SELECT TOP {limit}\n' + query[len("SELECT "):]
1637        elif self.flavor == 'oracle':
1638            query = (
1639                f"SELECT * FROM (\n  {query}\n)\n"
1640                + f"WHERE ROWNUM IN ({', '.join([str(i) for i in range(1, limit+1)])})"
1641            )
1642        else:
1643            query += f"\nLIMIT {limit}"
1644
1645    if debug:
1646        to_print = (
1647            []
1648            + ([f"begin='{begin}'"] if begin else [])
1649            + ([f"end='{end}'"] if end else [])
1650            + ([f"params={params}"] if params else [])
1651        )
1652        dprint("Getting pipe data with constraints: " + items_str(to_print, quotes=False))
1653
1654    return query

Return the SELECT query for retrieving a pipe's data from its instance.

Parameters
  • pipe (mrsm.Pipe:): The pipe to get data from.
  • select_columns (Optional[List[str]], default None): If provided, only select these given columns. Otherwise select all available columns (i.e. SELECT *).
  • omit_columns (Optional[List[str]], default None): If provided, remove these columns from the selection.
  • begin (Union[datetime, int, str, None], default None): If provided, get rows newer than or equal to this value.
  • end (Union[datetime, str, None], default None): If provided, get rows older than or equal to this value.
  • params (Optional[Dict[str, Any]], default None): Additional parameters to filter by. See meerschaum.connectors.sql.build_where.
  • order (Optional[str], default None): The selection order for all of the indices in the query. If None, omit the ORDER BY clause.
  • sort_datetimes (bool, default False): Alias for order='desc'.
  • limit (Optional[int], default None): If specified, limit the number of rows retrieved to this value.
  • begin_add_minutes (int, default 0): The number of minutes to add to the begin datetime (i.e. DATEADD).
  • end_add_minutes (int, default 0): The number of minutes to add to the end datetime (i.e. DATEADD).
  • chunksize (Optional[int], default -1): The size of dataframe chunks to load into memory.
  • replace_nulls (Optional[str], default None): If provided, replace null values with this value.
  • skip_existing_cols_check (bool, default False): If True, do not verify that querying columns are actually on the table.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A SELECT query to retrieve a pipe's data.
def register_pipe( self, pipe: meerschaum.Pipe, debug: bool = False) -> Tuple[bool, str]:
26def register_pipe(
27    self,
28    pipe: mrsm.Pipe,
29    debug: bool = False,
30) -> SuccessTuple:
31    """
32    Register a new pipe.
33    A pipe's attributes must be set before registering.
34    """
35    from meerschaum.utils.packages import attempt_import
36    from meerschaum.utils.sql import json_flavors
37
38    ### ensure pipes table exists
39    from meerschaum.connectors.sql.tables import get_tables
40    pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes']
41
42    if pipe.id is not None:
43        return False, f"{pipe} is already registered."
44
45    ### NOTE: if `parameters` is supplied in the Pipe constructor,
46    ###       then `pipe.parameters` will exist and not be fetched from the database.
47
48    ### 1. Prioritize the Pipe object's `parameters` first.
49    ###    E.g. if the user manually sets the `parameters` property
50    ###    or if the Pipe already exists
51    ###    (which shouldn't be able to be registered anyway but that's an issue for later).
52    parameters = None
53    try:
54        parameters = pipe.get_parameters(apply_symlinks=False)
55    except Exception as e:
56        if debug:
57            dprint(str(e))
58        parameters = None
59
60    ### ensure `parameters` is a dictionary
61    if parameters is None:
62        parameters = {}
63
64    import json
65    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
66    values = {
67        'connector_keys' : pipe.connector_keys,
68        'metric_key'     : pipe.metric_key,
69        'location_key'   : pipe.location_key,
70        'parameters'     : (
71            json.dumps(parameters)
72            if self.flavor not in json_flavors
73            else parameters
74        ),
75    }
76    query = sqlalchemy.insert(pipes_tbl).values(**values)
77    result = self.exec(query, debug=debug)
78    if result is None:
79        return False, f"Failed to register {pipe}."
80    return True, f"Successfully registered {pipe}."

Register a new pipe. A pipe's attributes must be set before registering.

def edit_pipe( self, pipe: meerschaum.Pipe, patch: bool = False, debug: bool = False, **kw: Any) -> Tuple[bool, str]:
 83def edit_pipe(
 84    self,
 85    pipe: mrsm.Pipe,
 86    patch: bool = False,
 87    debug: bool = False,
 88    **kw : Any
 89) -> SuccessTuple:
 90    """
 91    Persist a Pipe's parameters to its database.
 92
 93    Parameters
 94    ----------
 95    pipe: mrsm.Pipe, default None
 96        The pipe to be edited.
 97    patch: bool, default False
 98        If patch is `True`, update the existing parameters by cascading.
 99        Otherwise overwrite the parameters (default).
100    debug: bool, default False
101        Verbosity toggle.
102    """
103
104    if pipe.id is None:
105        return False, f"{pipe} is not registered and cannot be edited."
106
107    from meerschaum.utils.packages import attempt_import
108    from meerschaum.utils.sql import json_flavors
109    if not patch:
110        parameters = pipe.__dict__.get('_attributes', {}).get('parameters', {})
111    else:
112        from meerschaum import Pipe
113        from meerschaum.config._patch import apply_patch_to_config
114        original_parameters = Pipe(
115            pipe.connector_keys, pipe.metric_key, pipe.location_key,
116            mrsm_instance=pipe.instance_keys
117        ).get_parameters(apply_symlinks=False)
118        parameters = apply_patch_to_config(
119            original_parameters,
120            pipe._attributes['parameters']
121        )
122
123    ### ensure pipes table exists
124    from meerschaum.connectors.sql.tables import get_tables
125    pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes']
126
127    import json
128    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
129
130    values = {
131        'parameters': (
132            json.dumps(parameters)
133            if self.flavor not in json_flavors
134            else parameters
135        ),
136    }
137    q = sqlalchemy.update(pipes_tbl).values(**values).where(
138        pipes_tbl.c.pipe_id == pipe.id
139    )
140
141    result = self.exec(q, debug=debug)
142    message = (
143        f"Successfully edited {pipe}."
144        if result is not None else f"Failed to edit {pipe}."
145    )
146    return (result is not None), message

Persist a Pipe's parameters to its database.

Parameters
  • pipe (mrsm.Pipe, default None): The pipe to be edited.
  • patch (bool, default False): If patch is True, update the existing parameters by cascading. Otherwise overwrite the parameters (default).
  • debug (bool, default False): Verbosity toggle.
def get_pipe_id(self, pipe: meerschaum.Pipe, debug: bool = False) -> Any:
1657def get_pipe_id(
1658    self,
1659    pipe: mrsm.Pipe,
1660    debug: bool = False,
1661) -> Any:
1662    """
1663    Get a Pipe's ID from the pipes table.
1664    """
1665    if pipe.temporary:
1666        return None
1667    from meerschaum.utils.packages import attempt_import
1668    sqlalchemy = attempt_import('sqlalchemy')
1669    from meerschaum.connectors.sql.tables import get_tables
1670    pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes']
1671
1672    query = sqlalchemy.select(pipes_tbl.c.pipe_id).where(
1673        pipes_tbl.c.connector_keys == pipe.connector_keys
1674    ).where(
1675        pipes_tbl.c.metric_key == pipe.metric_key
1676    ).where(
1677        (pipes_tbl.c.location_key == pipe.location_key) if pipe.location_key is not None
1678        else pipes_tbl.c.location_key.is_(None)
1679    )
1680    _id = self.value(query, debug=debug, silent=pipe.temporary)
1681    if _id is not None:
1682        _id = int(_id)
1683    return _id

Get a Pipe's ID from the pipes table.

def get_pipe_attributes( self, pipe: meerschaum.Pipe, debug: bool = False) -> Dict[str, Any]:
1686def get_pipe_attributes(
1687    self,
1688    pipe: mrsm.Pipe,
1689    debug: bool = False,
1690) -> Dict[str, Any]:
1691    """
1692    Get a Pipe's attributes dictionary.
1693    """
1694    from meerschaum.connectors.sql.tables import get_tables
1695    from meerschaum.utils.packages import attempt_import
1696    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
1697
1698    if pipe.id is None:
1699        return {}
1700
1701    pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes']
1702
1703    q = sqlalchemy.select(pipes_tbl).where(pipes_tbl.c.pipe_id == pipe.id)
1704    if debug:
1705        dprint(q)
1706    result = self.exec(q, debug=debug) if self.flavor != 'duckdb' else self.read(q, debug=debug)
1707    if result is None:
1708        raise RuntimeError(f"Failed to read metadata for {pipe} from {self}.")
1709    rows = (
1710        result.mappings().all()
1711        if self.flavor != 'duckdb'
1712        else result.to_dict(orient='records')
1713    )
1714    if not rows:
1715        return {}
1716    attributes = dict(rows[0])
1717
1718    ### handle non-PostgreSQL databases (text vs JSON)
1719    if not isinstance(attributes.get('parameters', None), dict):
1720        try:
1721            import json
1722            parameters = json.loads(attributes['parameters'])
1723            if isinstance(parameters, str) and parameters[0] == '{':
1724                parameters = json.loads(parameters)
1725            attributes['parameters'] = parameters
1726        except Exception:
1727            attributes['parameters'] = {}
1728
1729    return attributes

Get a Pipe's attributes dictionary.

def sync_pipe( self, pipe: meerschaum.Pipe, df: 'Union[pd.DataFrame, str, Dict[Any, Any], None]' = None, begin: Union[datetime.datetime, int, NoneType] = None, end: Union[datetime.datetime, int, NoneType] = None, chunksize: Optional[int] = -1, check_existing: bool = True, blocking: bool = True, debug: bool = False, _check_temporary_tables: bool = True, **kw: Any) -> Tuple[bool, str]:
1904def sync_pipe(
1905    self,
1906    pipe: mrsm.Pipe,
1907    df: Union[pd.DataFrame, str, Dict[Any, Any], None] = None,
1908    begin: Union[datetime, int, None] = None,
1909    end: Union[datetime, int, None] = None,
1910    chunksize: Optional[int] = -1,
1911    check_existing: bool = True,
1912    blocking: bool = True,
1913    debug: bool = False,
1914    _check_temporary_tables: bool = True,
1915    **kw: Any
1916) -> SuccessTuple:
1917    """
1918    Sync a pipe using a database connection.
1919
1920    Parameters
1921    ----------
1922    pipe: mrsm.Pipe
1923        The Meerschaum Pipe instance into which to sync the data.
1924
1925    df: Union[pandas.DataFrame, str, Dict[Any, Any], List[Dict[str, Any]]]
1926        An optional DataFrame or equivalent to sync into the pipe.
1927        Defaults to `None`.
1928
1929    begin: Union[datetime, int, None], default None
1930        Optionally specify the earliest datetime to search for data.
1931        Defaults to `None`.
1932
1933    end: Union[datetime, int, None], default None
1934        Optionally specify the latest datetime to search for data.
1935        Defaults to `None`.
1936
1937    chunksize: Optional[int], default -1
1938        Specify the number of rows to sync per chunk.
1939        If `-1`, resort to system configuration (default is `900`).
1940        A `chunksize` of `None` will sync all rows in one transaction.
1941        Defaults to `-1`.
1942
1943    check_existing: bool, default True
1944        If `True`, pull and diff with existing data from the pipe. Defaults to `True`.
1945
1946    blocking: bool, default True
1947        If `True`, wait for sync to finish and return its result, otherwise asyncronously sync.
1948        Defaults to `True`.
1949
1950    debug: bool, default False
1951        Verbosity toggle. Defaults to False.
1952
1953    kw: Any
1954        Catch-all for keyword arguments.
1955
1956    Returns
1957    -------
1958    A `SuccessTuple` of success (`bool`) and message (`str`).
1959    """
1960    from meerschaum.utils.packages import import_pandas
1961    from meerschaum.utils.sql import (
1962        get_update_queries,
1963        sql_item_name,
1964        UPDATE_QUERIES,
1965        get_reset_autoincrement_queries,
1966    )
1967    from meerschaum.utils.dtypes import get_current_timestamp
1968    from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type
1969    from meerschaum.utils.dataframe import get_special_cols
1970    from meerschaum import Pipe
1971    import time
1972    import copy
1973    pd = import_pandas()
1974    if df is None:
1975        msg = f"DataFrame is None. Cannot sync {pipe}."
1976        warn(msg)
1977        return False, msg
1978
1979    start = time.perf_counter()
1980    pipe_name = sql_item_name(pipe.target, self.flavor, schema=self.get_pipe_schema(pipe))
1981    dtypes = pipe.get_dtypes(debug=debug)
1982
1983    if not pipe.temporary and not pipe.id:
1984        register_tuple = pipe.register(debug=debug)
1985        if not register_tuple[0]:
1986            return register_tuple
1987
1988    ### df is the dataframe returned from the remote source
1989    ### via the connector
1990    if debug:
1991        dprint("Fetched data:\n" + str(df))
1992
1993    if not isinstance(df, pd.DataFrame):
1994        df = pipe.enforce_dtypes(
1995            df,
1996            chunksize=chunksize,
1997            safe_copy=kw.get('safe_copy', False),
1998            dtypes=dtypes,
1999            debug=debug,
2000        )
2001
2002    ### if table does not exist, create it with indices
2003    is_new = False
2004    if not pipe.exists(debug=debug):
2005        check_existing = False
2006        is_new = True
2007    else:
2008        ### Check for new columns.
2009        add_cols_queries = self.get_add_columns_queries(pipe, df, debug=debug)
2010        if add_cols_queries:
2011            pipe._clear_cache_key('_columns_types', debug=debug)
2012            pipe._clear_cache_key('_columns_indices', debug=debug)
2013            if not self.exec_queries(add_cols_queries, debug=debug):
2014                warn(f"Failed to add new columns to {pipe}.")
2015
2016        alter_cols_queries = self.get_alter_columns_queries(pipe, df, debug=debug)
2017        if alter_cols_queries:
2018            pipe._clear_cache_key('_columns_types', debug=debug)
2019            pipe._clear_cache_key('_columns_types', debug=debug)
2020            if not self.exec_queries(alter_cols_queries, debug=debug):
2021                warn(f"Failed to alter columns for {pipe}.")
2022
2023    upsert = pipe.parameters.get('upsert', False) and (self.flavor + '-upsert') in UPDATE_QUERIES
2024    if upsert:
2025        check_existing = False
2026    kw['safe_copy'] = kw.get('safe_copy', False)
2027
2028    unseen_df, update_df, delta_df = (
2029        pipe.filter_existing(
2030            df,
2031            chunksize=chunksize,
2032            debug=debug,
2033            **kw
2034        ) if check_existing else (df, None, df)
2035    )
2036    if upsert:
2037        unseen_df, update_df, delta_df = (df.head(0), df, df)
2038
2039    if debug:
2040        dprint("Delta data:\n" + str(delta_df))
2041        dprint("Unseen data:\n" + str(unseen_df))
2042        if update_df is not None:
2043            dprint(("Update" if not upsert else "Upsert") + " data:\n" + str(update_df))
2044
2045    if_exists = kw.get('if_exists', 'append')
2046    if 'if_exists' in kw:
2047        kw.pop('if_exists')
2048    if 'name' in kw:
2049        kw.pop('name')
2050
2051    ### Insert new data into the target table.
2052    unseen_kw = copy.deepcopy(kw)
2053    unseen_kw.update({
2054        'name': pipe.target,
2055        'if_exists': if_exists,
2056        'debug': debug,
2057        'as_dict': True,
2058        'safe_copy': kw.get('safe_copy', False),
2059        'chunksize': chunksize,
2060        'dtype': self.get_to_sql_dtype(pipe, unseen_df, update_dtypes=True),
2061        'schema': self.get_pipe_schema(pipe),
2062    })
2063
2064    dt_col = pipe.columns.get('datetime', None)
2065    primary_key = pipe.columns.get('primary', None)
2066    autoincrement = (
2067        pipe.parameters.get('autoincrement', False)
2068        or (
2069            is_new
2070            and primary_key
2071            and primary_key
2072            not in dtypes
2073            and primary_key not in unseen_df.columns
2074        )
2075    )
2076    if autoincrement and autoincrement not in pipe.parameters:
2077        update_success, update_msg = pipe.update_parameters(
2078            {'autoincrement': autoincrement},
2079            debug=debug,
2080        )
2081        if not update_success:
2082            return update_success, update_msg
2083
2084    def _check_pk(_df_to_clear):
2085        if _df_to_clear is None:
2086            return
2087        if primary_key not in _df_to_clear.columns:
2088            return
2089        if not _df_to_clear[primary_key].notnull().any():
2090            del _df_to_clear[primary_key]
2091
2092    autoincrement_needs_reset = bool(
2093        autoincrement
2094        and primary_key
2095        and primary_key in unseen_df.columns
2096        and unseen_df[primary_key].notnull().any()
2097    )
2098    if autoincrement and primary_key:
2099        for _df_to_clear in (unseen_df, update_df, delta_df):
2100            _check_pk(_df_to_clear)
2101
2102    if is_new:
2103        create_success, create_msg = self.create_pipe_table_from_df(
2104            pipe,
2105            unseen_df,
2106            debug=debug,
2107        )
2108        if not create_success:
2109            return create_success, create_msg
2110
2111    ### Pre-create native range partitions (non-TimescaleDB) so the rows about to be written
2112    ### land in an existing partition. No-op for non-partitioned pipes.
2113    if self._should_partition(pipe):
2114        for _part_df in (unseen_df, update_df):
2115            if _part_df is not None and len(_part_df) > 0:
2116                part_success, part_msg = self._create_missing_partitions(
2117                    pipe, _part_df, debug=debug,
2118                )
2119                if not part_success:
2120                    return part_success, part_msg
2121
2122    do_identity_insert = bool(
2123        self.flavor in ('mssql',)
2124        and primary_key
2125        and primary_key in unseen_df.columns
2126        and autoincrement
2127    )
2128    stats = {'success': True, 'msg': ''}
2129    if len(unseen_df) > 0:
2130        with self.engine.connect() as connection:
2131            with connection.begin():
2132                if do_identity_insert:
2133                    identity_on_result = self.exec(
2134                        f"SET IDENTITY_INSERT {pipe_name} ON",
2135                        commit=False,
2136                        _connection=connection,
2137                        close=False,
2138                        debug=debug,
2139                    )
2140                    if identity_on_result is None:
2141                        return False, f"Could not enable identity inserts on {pipe}."
2142
2143                stats = self.to_sql(
2144                    unseen_df,
2145                    _connection=connection,
2146                    **unseen_kw
2147                )
2148
2149                if do_identity_insert:
2150                    identity_off_result = self.exec(
2151                        f"SET IDENTITY_INSERT {pipe_name} OFF",
2152                        commit=False,
2153                        _connection=connection,
2154                        close=False,
2155                        debug=debug,
2156                    )
2157                    if identity_off_result is None:
2158                        return False, f"Could not disable identity inserts on {pipe}."
2159
2160    if is_new:
2161        if not self.create_indices(pipe, debug=debug):
2162            warn(f"Failed to create indices for {pipe}. Continuing...")
2163
2164    if autoincrement_needs_reset:
2165        reset_autoincrement_queries = get_reset_autoincrement_queries(
2166            pipe.target,
2167            primary_key,
2168            self,
2169            schema=self.get_pipe_schema(pipe),
2170            debug=debug,
2171        )
2172        results = self.exec_queries(reset_autoincrement_queries, debug=debug)
2173        for result in results:
2174            if result is None:
2175                warn(f"Could not reset auto-incrementing primary key for {pipe}.", stack=False)
2176
2177    if update_df is not None and len(update_df) > 0:
2178        temp_target = self.get_temporary_target(
2179            pipe.target,
2180            label=('update' if not upsert else 'upsert'),
2181        )
2182        self._log_temporary_tables_creation(temp_target, create=(not pipe.temporary), debug=debug)
2183        pipe_dtypes = pipe.dtypes
2184        update_dtypes = {
2185            **{
2186                col: str(typ)
2187                for col, typ in update_df.dtypes.items()
2188            },
2189            **get_special_cols(update_df),
2190            **{
2191                col: pipe_dtypes[col]
2192                for col in update_df.columns
2193                if col in pipe_dtypes and update_df[col].isnull().all()
2194            },
2195        }
2196
2197        temp_pipe = Pipe(
2198            pipe.connector_keys.replace(':', '_') + '_', pipe.metric_key, pipe.location_key,
2199            instance=pipe.instance_keys,
2200            columns={
2201                (ix_key if ix_key != 'primary' else 'primary_'): ix
2202                for ix_key, ix in pipe.columns.items()
2203                if ix and ix in update_df.columns
2204            },
2205            dtypes=update_dtypes,
2206            target=temp_target,
2207            temporary=True,
2208            enforce=False,
2209            static=True,
2210            autoincrement=False,
2211            cache=False,
2212            parameters={
2213                'schema': self.internal_schema,
2214                'hypertable': False,
2215            },
2216        )
2217        _temp_columns_types = {
2218            col: get_db_type_from_pd_type(typ, self.flavor)
2219            for col, typ in update_dtypes.items()
2220        }
2221        temp_pipe._cache_value('_columns_types', _temp_columns_types, memory_only=True, debug=debug)
2222        temp_pipe._cache_value('_skip_check_indices', True, memory_only=True, debug=debug)
2223        now_ts = get_current_timestamp('ms', as_int=True) / 1000
2224        temp_pipe._cache_value('_columns_types_timestamp', now_ts, memory_only=True, debug=debug)
2225        temp_success, temp_msg = temp_pipe.sync(update_df, check_existing=False, debug=debug)
2226        if not temp_success:
2227            return temp_success, temp_msg
2228
2229        existing_cols = pipe.get_columns_types(debug=debug)
2230        ### A partitioned table (TimescaleDB hypertable or a native range-partitioned table on
2231        ### PostgreSQL/MySQL/MSSQL) folds the datetime column into its composite primary key, so the
2232        ### upsert conflict target must include it too — `ON CONFLICT (primary_key)` alone has no
2233        ### matching unique constraint. Non-partitioned tables keep the historical primary-key-only
2234        ### semantics ("the primary key is the identity, regardless of datetime").
2235        partition_upsert = bool(
2236            dt_col
2237            and dt_col in update_df.columns
2238            and (
2239                self.flavor in ('timescaledb', 'timescaledb-ha')
2240                or self._should_partition(pipe)
2241            )
2242        )
2243        join_cols = [
2244            col
2245            for col_key, col in pipe.columns.items()
2246            if col and col in existing_cols
2247        ] if not primary_key or self.flavor == 'oracle' else (
2248            [dt_col, primary_key]
2249            if partition_upsert
2250            else [primary_key]
2251        )
2252        update_queries = get_update_queries(
2253            pipe.target,
2254            temp_target,
2255            self,
2256            join_cols,
2257            upsert=upsert,
2258            schema=self.get_pipe_schema(pipe),
2259            patch_schema=self.internal_schema,
2260            target_cols_types=pipe.get_columns_types(debug=debug),
2261            patch_cols_types=_temp_columns_types,
2262            datetime_col=(dt_col if dt_col in update_df.columns else None),
2263            identity_insert=(autoincrement and primary_key in update_df.columns),
2264            null_indices=pipe.null_indices,
2265            cast_columns=pipe.enforce,
2266            debug=debug,
2267        )
2268        update_results = self.exec_queries(
2269            update_queries,
2270            break_on_error=True,
2271            rollback=True,
2272            debug=debug,
2273        )
2274        update_success = all(update_results)
2275        self._log_temporary_tables_creation(
2276            temp_target,
2277            ready_to_drop=True,
2278            create=(not pipe.temporary),
2279            debug=debug,
2280        )
2281        if not update_success:
2282            warn(f"Failed to apply update to {pipe}.")
2283        stats['success'] = stats['success'] and update_success
2284        stats['msg'] = (
2285            (stats.get('msg', '') + f'\nFailed to apply update to {pipe}.').lstrip()
2286            if not update_success
2287            else stats.get('msg', '')
2288        )
2289
2290    stop = time.perf_counter()
2291    success = stats['success']
2292    if not success:
2293        return success, stats['msg'] or str(stats)
2294
2295    unseen_count = len(unseen_df.index) if unseen_df is not None else 0
2296    update_count = len(update_df.index) if update_df is not None else 0
2297    msg = (
2298        (
2299            f"Inserted {unseen_count:,}, "
2300            + f"updated {update_count:,} rows."
2301        )
2302        if not upsert
2303        else (
2304            f"Upserted {update_count:,} row"
2305            + ('s' if update_count != 1 else '')
2306            + "."
2307        )
2308    )
2309    if debug:
2310        msg = msg[:-1] + (
2311            f"\non table {sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))}\n"
2312            + f"in {round(stop - start, 2)} seconds."
2313        )
2314
2315    if _check_temporary_tables:
2316        drop_stale_success, drop_stale_msg = self._drop_old_temporary_tables(
2317            refresh=False, debug=debug
2318        )
2319        if not drop_stale_success:
2320            warn(drop_stale_msg)
2321
2322    return success, msg

Sync a pipe using a database connection.

Parameters
  • pipe (mrsm.Pipe): The Meerschaum Pipe instance into which to sync the data.
  • df (Union[pandas.DataFrame, str, Dict[Any, Any], List[Dict[str, Any]]]): An optional DataFrame or equivalent to sync into the pipe. Defaults to None.
  • begin (Union[datetime, int, None], default None): Optionally specify the earliest datetime to search for data. Defaults to None.
  • end (Union[datetime, int, None], default None): Optionally specify the latest datetime to search for data. Defaults to None.
  • chunksize (Optional[int], default -1): Specify the number of rows to sync per chunk. If -1, resort to system configuration (default is 900). A chunksize of None will sync all rows in one transaction. Defaults to -1.
  • check_existing (bool, default True): If True, pull and diff with existing data from the pipe. Defaults to True.
  • blocking (bool, default True): If True, wait for sync to finish and return its result, otherwise asyncronously sync. Defaults to True.
  • debug (bool, default False): Verbosity toggle. Defaults to False.
  • kw (Any): Catch-all for keyword arguments.
Returns
  • A SuccessTuple of success (bool) and message (str).
def sync_pipe_inplace( self, pipe: meerschaum.Pipe, params: Optional[Dict[str, Any]] = None, begin: Union[datetime.datetime, int, NoneType] = None, end: Union[datetime.datetime, int, NoneType] = None, chunksize: Optional[int] = -1, check_existing: bool = True, debug: bool = False, **kw: Any) -> Tuple[bool, str]:
2325def sync_pipe_inplace(
2326    self,
2327    pipe: 'mrsm.Pipe',
2328    params: Optional[Dict[str, Any]] = None,
2329    begin: Union[datetime, int, None] = None,
2330    end: Union[datetime, int, None] = None,
2331    chunksize: Optional[int] = -1,
2332    check_existing: bool = True,
2333    debug: bool = False,
2334    **kw: Any
2335) -> SuccessTuple:
2336    """
2337    If a pipe's connector is the same as its instance connector,
2338    it's more efficient to sync the pipe in-place rather than reading data into Pandas.
2339
2340    Parameters
2341    ----------
2342    pipe: mrsm.Pipe
2343        The pipe whose connector is the same as its instance.
2344
2345    params: Optional[Dict[str, Any]], default None
2346        Optional params dictionary to build the `WHERE` clause.
2347        See `meerschaum.utils.sql.build_where`.
2348
2349    begin: Union[datetime, int, None], default None
2350        Optionally specify the earliest datetime to search for data.
2351        Defaults to `None`.
2352
2353    end: Union[datetime, int, None], default None
2354        Optionally specify the latest datetime to search for data.
2355        Defaults to `None`.
2356
2357    chunksize: Optional[int], default -1
2358        Specify the number of rows to sync per chunk.
2359        If `-1`, resort to system configuration (default is `900`).
2360        A `chunksize` of `None` will sync all rows in one transaction.
2361        Defaults to `-1`.
2362
2363    check_existing: bool, default True
2364        If `True`, pull and diff with existing data from the pipe.
2365
2366    debug: bool, default False
2367        Verbosity toggle.
2368
2369    Returns
2370    -------
2371    A SuccessTuple.
2372    """
2373    if self.flavor == 'duckdb':
2374        return pipe.sync(
2375            params=params,
2376            begin=begin,
2377            end=end,
2378            chunksize=chunksize,
2379            check_existing=check_existing,
2380            debug=debug,
2381            _inplace=False,
2382            **kw
2383        )
2384    from meerschaum.utils.sql import (
2385        sql_item_name,
2386        get_update_queries,
2387        get_null_replacement,
2388        get_create_table_queries,
2389        get_create_schema_if_not_exists_queries,
2390        get_table_cols_types,
2391        session_execute,
2392        dateadd_str,
2393        UPDATE_QUERIES,
2394    )
2395    from meerschaum.utils.dtypes.sql import (
2396        get_pd_type_from_db_type,
2397        get_db_type_from_pd_type,
2398    )
2399    from meerschaum.utils.misc import generate_password
2400
2401    transaction_id_length = (
2402        mrsm.get_config(
2403            'system', 'connectors', 'sql', 'instance', 'temporary_target', 'transaction_id_length'
2404        )
2405    )
2406    transact_id = generate_password(transaction_id_length)
2407
2408    internal_schema = self.internal_schema
2409    target = pipe.target
2410    temp_table_roots = ['backtrack', 'new', 'delta', 'joined', 'unseen', 'update']
2411    temp_tables = {
2412        table_root: self.get_temporary_target(target, transact_id=transact_id, label=table_root)
2413        for table_root in temp_table_roots
2414    }
2415    temp_table_names = {
2416        table_root: sql_item_name(table_name_raw, self.flavor, internal_schema)
2417        for table_root, table_name_raw in temp_tables.items()
2418    }
2419    temp_table_aliases = {
2420        table_root: sql_item_name(table_root, self.flavor)
2421        for table_root in temp_table_roots
2422    }
2423    table_alias_as = " AS" if self.flavor != 'oracle' else ''
2424    metadef = self.get_pipe_metadef(
2425        pipe,
2426        params=params,
2427        begin=begin,
2428        end=end,
2429        check_existing=check_existing,
2430        debug=debug,
2431    )
2432    pipe_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
2433    upsert = pipe.parameters.get('upsert', False) and f'{self.flavor}-upsert' in UPDATE_QUERIES
2434    static = pipe.parameters.get('static', False)
2435    database = getattr(self, 'database', self.parse_uri(self.URI).get('database', None))
2436    primary_key = pipe.columns.get('primary', None)
2437    primary_key_typ = pipe.dtypes.get(primary_key, None) if primary_key else None
2438    primary_key_db_type = (
2439        get_db_type_from_pd_type(primary_key_typ, self.flavor)
2440        if primary_key_typ
2441        else None
2442    )
2443    if not {col_key: col for col_key, col in pipe.columns.items() if col_key and col}:
2444        return False, "Cannot sync in-place without index columns."
2445
2446    autoincrement = pipe.parameters.get('autoincrement', False)
2447    dt_col = pipe.columns.get('datetime', None)
2448    dt_col_name = sql_item_name(dt_col, self.flavor, None) if dt_col else None
2449    dt_typ = pipe.dtypes.get(dt_col, 'datetime') if dt_col else None
2450    dt_db_type = get_db_type_from_pd_type(dt_typ, self.flavor) if dt_typ else None
2451
2452    def clean_up_temp_tables(ready_to_drop: bool = False):
2453        log_success, log_msg = self._log_temporary_tables_creation(
2454            [
2455                table
2456                for table in temp_tables.values()
2457            ] if not upsert else [temp_tables['update']],
2458            ready_to_drop=ready_to_drop,
2459            create=(not pipe.temporary),
2460            debug=debug,
2461        )
2462        if not log_success:
2463            warn(log_msg)
2464        drop_stale_success, drop_stale_msg = self._drop_old_temporary_tables(
2465            refresh=False,
2466            debug=debug,
2467        )
2468        if not drop_stale_success:
2469            warn(drop_stale_msg)
2470        return drop_stale_success, drop_stale_msg
2471
2472    sqlalchemy, sqlalchemy_orm = mrsm.attempt_import(
2473        'sqlalchemy',
2474        'sqlalchemy.orm',
2475    )
2476    if not pipe.exists(debug=debug):
2477        schema = self.get_pipe_schema(pipe)
2478        create_pipe_queries = get_create_table_queries(
2479            metadef,
2480            pipe.target,
2481            self.flavor,
2482            schema=schema,
2483            primary_key=primary_key,
2484            primary_key_db_type=primary_key_db_type,
2485            autoincrement=autoincrement,
2486            datetime_column=dt_col,
2487        )
2488        if schema:
2489            create_pipe_queries = (
2490                get_create_schema_if_not_exists_queries(schema, self.flavor)
2491                + create_pipe_queries
2492            )
2493
2494        results = self.exec_queries(create_pipe_queries, debug=debug)
2495        if not all(results):
2496            _ = clean_up_temp_tables()
2497            return False, f"Could not insert new data into {pipe} from its SQL query definition."
2498
2499        if not self.create_indices(pipe, debug=debug):
2500            warn(f"Failed to create indices for {pipe}. Continuing...")
2501
2502        rowcount = pipe.get_rowcount(debug=debug)
2503        _ = clean_up_temp_tables()
2504        return True, f"Inserted {rowcount:,}, updated 0 rows."
2505
2506    session = sqlalchemy_orm.Session(self.engine)
2507    connectable = session if self.flavor != 'duckdb' else self
2508
2509    create_new_query = get_create_table_queries(
2510        metadef,
2511        temp_tables[('new') if not upsert else 'update'],
2512        self.flavor,
2513        schema=internal_schema,
2514    )[0]
2515    (create_new_success, create_new_msg), create_new_results = session_execute(
2516        session,
2517        create_new_query,
2518        with_results=True,
2519        debug=debug,
2520    )
2521    if not create_new_success:
2522        _ = clean_up_temp_tables()
2523        return create_new_success, create_new_msg
2524    new_count = create_new_results[0].rowcount if create_new_results else 0
2525
2526    new_cols_types = get_table_cols_types(
2527        temp_tables[('new' if not upsert else 'update')],
2528        connectable=connectable,
2529        flavor=self.flavor,
2530        schema=internal_schema,
2531        database=database,
2532        debug=debug,
2533    ) if not static else pipe.get_columns_types(debug=debug)
2534    if not new_cols_types:
2535        return False, f"Failed to get new columns for {pipe}."
2536
2537    new_cols = {
2538        str(col_name): get_pd_type_from_db_type(str(col_type))
2539        for col_name, col_type in new_cols_types.items()
2540    }
2541    new_cols_str = '\n    ' + ',\n    '.join([
2542        sql_item_name(col, self.flavor)
2543        for col in new_cols
2544    ])
2545    def get_col_typ(col: str, cols_types: Dict[str, str]) -> str:
2546        if self.flavor == 'oracle' and new_cols_types.get(col, '').lower() == 'char':
2547            return new_cols_types[col]
2548        return cols_types[col]
2549
2550    add_cols_queries = self.get_add_columns_queries(pipe, new_cols, debug=debug)
2551    if add_cols_queries:
2552        pipe._clear_cache_key('_columns_types', debug=debug)
2553        pipe._clear_cache_key('_columns_indices', debug=debug)
2554        self.exec_queries(add_cols_queries, debug=debug)
2555
2556    alter_cols_queries = self.get_alter_columns_queries(pipe, new_cols, debug=debug)
2557    if alter_cols_queries:
2558        pipe._clear_cache_key('_columns_types', debug=debug)
2559        self.exec_queries(alter_cols_queries, debug=debug)
2560
2561    insert_queries = [
2562        (
2563            f"INSERT INTO {pipe_name} ({new_cols_str})\n"
2564            f"SELECT {new_cols_str}\nFROM {temp_table_names['new']}{table_alias_as}"
2565            f" {temp_table_aliases['new']}"
2566        )
2567    ] if not check_existing and not upsert else []
2568
2569    new_queries = insert_queries
2570    new_success, new_msg = (
2571        session_execute(session, new_queries, debug=debug)
2572        if new_queries
2573        else (True, "Success")
2574    )
2575    if not new_success:
2576        _ = clean_up_temp_tables()
2577        return new_success, new_msg
2578
2579    if not check_existing:
2580        session.commit()
2581        _ = clean_up_temp_tables()
2582        return True, f"Inserted {new_count}, updated 0 rows."
2583
2584    min_dt_col_name_da = dateadd_str(
2585        flavor=self.flavor, begin=f"MIN({dt_col_name})", db_type=dt_db_type,
2586    )
2587    max_dt_col_name_da = dateadd_str(
2588        flavor=self.flavor, begin=f"MAX({dt_col_name})", db_type=dt_db_type,
2589    )
2590
2591    (new_dt_bounds_success, new_dt_bounds_msg), new_dt_bounds_results = session_execute(
2592        session,
2593        [
2594            "SELECT\n"
2595            f"    {min_dt_col_name_da} AS {sql_item_name('min_dt', self.flavor)},\n"
2596            f"    {max_dt_col_name_da} AS {sql_item_name('max_dt', self.flavor)}\n"
2597            f"FROM {temp_table_names['new' if not upsert else 'update']}\n"
2598            f"WHERE {dt_col_name} IS NOT NULL"
2599        ],
2600        with_results=True,
2601        debug=debug,
2602    ) if dt_col and not upsert else ((True, "Success"), None)
2603    if not new_dt_bounds_success:
2604        return (
2605            new_dt_bounds_success,
2606            f"Could not determine in-place datetime bounds:\n{new_dt_bounds_msg}"
2607        )
2608
2609    if dt_col and not upsert:
2610        begin, end = new_dt_bounds_results[0].fetchone()
2611
2612    backtrack_def = self.get_pipe_data_query(
2613        pipe,
2614        begin=begin,
2615        end=end,
2616        begin_add_minutes=0,
2617        end_add_minutes=1,
2618        params=params,
2619        debug=debug,
2620        order=None,
2621    )
2622    create_backtrack_query = get_create_table_queries(
2623        backtrack_def,
2624        temp_tables['backtrack'],
2625        self.flavor,
2626        schema=internal_schema,
2627    )[0]
2628    (create_backtrack_success, create_backtrack_msg), create_backtrack_results = session_execute(
2629        session,
2630        create_backtrack_query,
2631        with_results=True,
2632        debug=debug,
2633    ) if not upsert else ((True, "Success"), None)
2634
2635    if not create_backtrack_success:
2636        _ = clean_up_temp_tables()
2637        return create_backtrack_success, create_backtrack_msg
2638
2639    backtrack_cols_types = get_table_cols_types(
2640        temp_tables['backtrack'],
2641        connectable=connectable,
2642        flavor=self.flavor,
2643        schema=internal_schema,
2644        database=database,
2645        debug=debug,
2646    ) if not (upsert or static) else new_cols_types
2647
2648    common_cols = [col for col in new_cols if col in backtrack_cols_types]
2649    primary_key = pipe.columns.get('primary', None)
2650    on_cols = {
2651        col: new_cols.get(col)
2652        for col_key, col in pipe.columns.items()
2653        if (
2654            col
2655            and
2656            col_key != 'value'
2657            and col in backtrack_cols_types
2658            and col in new_cols
2659        )
2660    } if not primary_key else {primary_key: new_cols.get(primary_key)}
2661    if not on_cols:
2662        raise ValueError("Cannot sync without common index columns.")
2663
2664    null_replace_new_cols_str = (
2665        '\n    ' + ',\n    '.join([
2666            f"COALESCE({temp_table_aliases['new']}.{sql_item_name(col, self.flavor)}, "
2667            + get_null_replacement(get_col_typ(col, new_cols_types), self.flavor)
2668            + ") AS "
2669            + sql_item_name(col, self.flavor, None)
2670            for col, typ in new_cols.items()
2671        ])
2672    )
2673
2674    select_delta_query = (
2675        "SELECT"
2676        + null_replace_new_cols_str
2677        + f"\nFROM {temp_table_names['new']}{table_alias_as} {temp_table_aliases['new']}\n"
2678        + f"LEFT OUTER JOIN {temp_table_names['backtrack']}{table_alias_as} {temp_table_aliases['backtrack']}"
2679        + "\n    ON\n    "
2680        + '\n    AND\n    '.join([
2681            (
2682                f"    COALESCE({temp_table_aliases['new']}."
2683                + sql_item_name(c, self.flavor, None)
2684                + ", "
2685                + get_null_replacement(get_col_typ(c, new_cols_types), self.flavor)
2686                + ")"
2687                + '\n        =\n    '
2688                + f"    COALESCE({temp_table_aliases['backtrack']}."
2689                + sql_item_name(c, self.flavor, None)
2690                + ", "
2691                + get_null_replacement(get_col_typ(c, backtrack_cols_types), self.flavor)
2692                + ") "
2693            ) for c in common_cols
2694        ])
2695        + "\nWHERE\n    "
2696        + '\n    AND\n    '.join([
2697            (
2698                f"{temp_table_aliases['backtrack']}." + sql_item_name(c, self.flavor) + ' IS NULL'
2699            ) for c in common_cols
2700        ])
2701    )
2702    create_delta_query = get_create_table_queries(
2703        select_delta_query,
2704        temp_tables['delta'],
2705        self.flavor,
2706        schema=internal_schema,
2707    )[0]
2708    create_delta_success, create_delta_msg = session_execute(
2709        session,
2710        create_delta_query,
2711        debug=debug,
2712    ) if not upsert else (True, "Success")
2713    if not create_delta_success:
2714        _ = clean_up_temp_tables()
2715        return create_delta_success, create_delta_msg
2716
2717    delta_cols_types = get_table_cols_types(
2718        temp_tables['delta'],
2719        connectable=connectable,
2720        flavor=self.flavor,
2721        schema=internal_schema,
2722        database=database,
2723        debug=debug,
2724    ) if not (upsert or static) else new_cols_types
2725
2726    ### This is a weird bug on SQLite.
2727    ### Sometimes the backtrack dtypes are all empty strings.
2728    if not all(delta_cols_types.values()):
2729        delta_cols_types = new_cols_types
2730
2731    delta_cols = {
2732        col: get_pd_type_from_db_type(typ)
2733        for col, typ in delta_cols_types.items()
2734    }
2735    delta_cols_str = ', '.join([
2736        sql_item_name(col, self.flavor)
2737        for col in delta_cols
2738    ])
2739
2740    select_joined_query = (
2741        "SELECT\n    "
2742        + (',\n    '.join([
2743            (
2744                f"{temp_table_aliases['delta']}." + sql_item_name(c, self.flavor, None)
2745                + " AS " + sql_item_name(c + '_delta', self.flavor, None)
2746            ) for c in delta_cols
2747        ]))
2748        + ",\n    "
2749        + (',\n    '.join([
2750            (
2751                f"{temp_table_aliases['backtrack']}." + sql_item_name(c, self.flavor, None)
2752                + " AS " + sql_item_name(c + '_backtrack', self.flavor, None)
2753            ) for c in backtrack_cols_types
2754        ]))
2755        + f"\nFROM {temp_table_names['delta']}{table_alias_as} {temp_table_aliases['delta']}\n"
2756        + f"LEFT OUTER JOIN {temp_table_names['backtrack']}{table_alias_as}"
2757        + f" {temp_table_aliases['backtrack']}"
2758        + "\n    ON\n    "
2759        + '\n    AND\n    '.join([
2760            (
2761                f"    COALESCE({temp_table_aliases['delta']}." + sql_item_name(c, self.flavor)
2762                + ", "
2763                + get_null_replacement(get_col_typ(c, new_cols_types), self.flavor) + ")"
2764                + '\n        =\n    '
2765                + f"    COALESCE({temp_table_aliases['backtrack']}." + sql_item_name(c, self.flavor)
2766                + ", "
2767                + get_null_replacement(get_col_typ(c, new_cols_types), self.flavor) + ")"
2768            ) for c, typ in on_cols.items()
2769        ])
2770    )
2771
2772    create_joined_query = get_create_table_queries(
2773        select_joined_query,
2774        temp_tables['joined'],
2775        self.flavor,
2776        schema=internal_schema,
2777    )[0]
2778    create_joined_success, create_joined_msg = session_execute(
2779        session,
2780        create_joined_query,
2781        debug=debug,
2782    ) if on_cols and not upsert else (True, "Success")
2783    if not create_joined_success:
2784        _ = clean_up_temp_tables()
2785        return create_joined_success, create_joined_msg
2786
2787    select_unseen_query = (
2788        "SELECT\n    "
2789        + (',\n    '.join([
2790            (
2791                "CASE\n        WHEN " + sql_item_name(c + '_delta', self.flavor, None)
2792                + " != " + get_null_replacement(get_col_typ(c, delta_cols_types), self.flavor)
2793                + " THEN " + sql_item_name(c + '_delta', self.flavor, None)
2794                + "\n        ELSE NULL\n    END"
2795                + " AS " + sql_item_name(c, self.flavor, None)
2796            ) for c, typ in delta_cols.items()
2797        ]))
2798        + f"\nFROM {temp_table_names['joined']}{table_alias_as} {temp_table_aliases['joined']}\n"
2799        + "WHERE\n    "
2800        + '\n    AND\n    '.join([
2801            (
2802                sql_item_name(c + '_backtrack', self.flavor, None) + ' IS NULL'
2803            ) for c in delta_cols
2804        ])
2805    )
2806    create_unseen_query = get_create_table_queries(
2807        select_unseen_query,
2808        temp_tables['unseen'],
2809        self.flavor,
2810        internal_schema,
2811    )[0]
2812    (create_unseen_success, create_unseen_msg), create_unseen_results = session_execute(
2813        session,
2814        create_unseen_query,
2815        with_results=True,
2816        debug=debug
2817    ) if not upsert else ((True, "Success"), None)
2818    if not create_unseen_success:
2819        _ = clean_up_temp_tables()
2820        return create_unseen_success, create_unseen_msg
2821
2822    select_update_query = (
2823        "SELECT\n    "
2824        + (',\n    '.join([
2825            (
2826                "CASE\n        WHEN " + sql_item_name(c + '_delta', self.flavor, None)
2827                + " != " + get_null_replacement(get_col_typ(c, delta_cols_types), self.flavor)
2828                + " THEN " + sql_item_name(c + '_delta', self.flavor, None)
2829                + "\n        ELSE NULL\n    END"
2830                + " AS " + sql_item_name(c, self.flavor, None)
2831            ) for c, typ in delta_cols.items()
2832        ]))
2833        + f"\nFROM {temp_table_names['joined']}{table_alias_as} {temp_table_aliases['joined']}\n"
2834        + "WHERE\n    "
2835        + '\n    OR\n    '.join([
2836            (
2837                sql_item_name(c + '_backtrack', self.flavor, None) + ' IS NOT NULL'
2838            ) for c in delta_cols
2839        ])
2840    )
2841
2842    create_update_query = get_create_table_queries(
2843        select_update_query,
2844        temp_tables['update'],
2845        self.flavor,
2846        internal_schema,
2847    )[0]
2848    (create_update_success, create_update_msg), create_update_results = session_execute(
2849        session,
2850        create_update_query,
2851        with_results=True,
2852        debug=debug,
2853    ) if on_cols and not upsert else ((True, "Success"), [])
2854    apply_update_queries = (
2855        get_update_queries(
2856            pipe.target,
2857            temp_tables['update'],
2858            session,
2859            on_cols,
2860            upsert=upsert,
2861            schema=self.get_pipe_schema(pipe),
2862            patch_schema=internal_schema,
2863            target_cols_types=pipe.get_columns_types(debug=debug),
2864            patch_cols_types=delta_cols_types,
2865            datetime_col=pipe.columns.get('datetime', None),
2866            flavor=self.flavor,
2867            null_indices=pipe.null_indices,
2868            cast_columns=pipe.enforce,
2869            debug=debug,
2870        )
2871        if on_cols else []
2872    )
2873
2874    apply_unseen_queries = [
2875        (
2876            f"INSERT INTO {pipe_name} ({delta_cols_str})\n"
2877            + f"SELECT {delta_cols_str}\nFROM "
2878            + (
2879                temp_table_names['unseen']
2880                if on_cols
2881                else temp_table_names['delta']
2882            )
2883        ),
2884    ]
2885
2886    (apply_unseen_success, apply_unseen_msg), apply_unseen_results = session_execute(
2887        session,
2888        apply_unseen_queries,
2889        with_results=True,
2890        debug=debug,
2891    ) if not upsert else ((True, "Success"), None)
2892    if not apply_unseen_success:
2893        _ = clean_up_temp_tables()
2894        return apply_unseen_success, apply_unseen_msg
2895    unseen_count = apply_unseen_results[0].rowcount if apply_unseen_results else 0
2896
2897    (apply_update_success, apply_update_msg), apply_update_results = session_execute(
2898        session,
2899        apply_update_queries,
2900        with_results=True,
2901        debug=debug,
2902    )
2903    if not apply_update_success:
2904        _ = clean_up_temp_tables()
2905        return apply_update_success, apply_update_msg
2906    update_count = apply_update_results[0].rowcount if apply_update_results else 0
2907
2908    session.commit()
2909
2910    msg = (
2911        f"Inserted {unseen_count:,}, updated {update_count:,} rows."
2912        if not upsert
2913        else f"Upserted {update_count:,} row" + ('s' if update_count != 1 else '') + "."
2914    )
2915    _ = clean_up_temp_tables(ready_to_drop=True)
2916
2917    return True, msg

If a pipe's connector is the same as its instance connector, it's more efficient to sync the pipe in-place rather than reading data into Pandas.

Parameters
  • pipe (mrsm.Pipe): The pipe whose connector is the same as its instance.
  • params (Optional[Dict[str, Any]], default None): Optional params dictionary to build the WHERE clause. See meerschaum.utils.sql.build_where.
  • begin (Union[datetime, int, None], default None): Optionally specify the earliest datetime to search for data. Defaults to None.
  • end (Union[datetime, int, None], default None): Optionally specify the latest datetime to search for data. Defaults to None.
  • chunksize (Optional[int], default -1): Specify the number of rows to sync per chunk. If -1, resort to system configuration (default is 900). A chunksize of None will sync all rows in one transaction. Defaults to -1.
  • check_existing (bool, default True): If True, pull and diff with existing data from the pipe.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A SuccessTuple.
def get_sync_time( self, pipe: meerschaum.Pipe, params: Optional[Dict[str, Any]] = None, newest: bool = True, remote: bool = False, debug: bool = False) -> Union[datetime.datetime, int, NoneType]:
2920def get_sync_time(
2921    self,
2922    pipe: 'mrsm.Pipe',
2923    params: Optional[Dict[str, Any]] = None,
2924    newest: bool = True,
2925    remote: bool = False,
2926    debug: bool = False,
2927) -> Union[datetime, int, None]:
2928    """Get a Pipe's most recent datetime value.
2929
2930    Parameters
2931    ----------
2932    pipe: mrsm.Pipe
2933        The pipe to get the sync time for.
2934
2935    params: Optional[Dict[str, Any]], default None
2936        Optional params dictionary to build the `WHERE` clause.
2937        See `meerschaum.utils.sql.build_where`.
2938
2939    newest: bool, default True
2940        If `True`, get the most recent datetime (honoring `params`).
2941        If `False`, get the oldest datetime (ASC instead of DESC).
2942
2943    remote: bool, default False
2944        If `True`, return the sync time for the remote fetch definition.
2945
2946    Returns
2947    -------
2948    A `datetime` object (or `int` if using an integer axis) if the pipe exists, otherwise `None`.
2949    """
2950    from meerschaum.utils.sql import sql_item_name, build_where, wrap_query_with_cte
2951    src_name = sql_item_name('src', self.flavor)
2952    table_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
2953
2954    dt_col = pipe.columns.get('datetime', None)
2955    if dt_col is None:
2956        return None
2957    dt_col_name = sql_item_name(dt_col, self.flavor, None)
2958
2959    if remote and pipe.connector.type != 'sql':
2960        warn(f"Cannot get the remote sync time for {pipe}.")
2961        return None
2962
2963    ASC_or_DESC = "DESC" if newest else "ASC"
2964    existing_cols = pipe.get_columns_types(debug=debug)
2965    if not remote and not existing_cols:
2966        return None
2967    valid_params = {}
2968    if params is not None:
2969        valid_params = {k: v for k, v in params.items() if k in existing_cols}
2970    flavor = self.flavor if not remote else pipe.connector.flavor
2971
2972    ### If no bounds are provided for the datetime column,
2973    ### add IS NOT NULL to the WHERE clause.
2974    if dt_col not in valid_params:
2975        valid_params[dt_col] = '_None'
2976    where = "" if not valid_params else build_where(valid_params, self)
2977    src_query = (
2978        f"SELECT {dt_col_name}\nFROM {table_name}{where}"
2979        if not remote
2980        else self.get_pipe_metadef(pipe, params=params, begin=None, end=None)
2981    )
2982
2983    base_query = (
2984        f"SELECT {dt_col_name}\n"
2985        f"FROM {src_name}\n"
2986        f"ORDER BY {dt_col_name} {ASC_or_DESC}\n"
2987        f"LIMIT 1"
2988    )
2989    if self.flavor == 'mssql':
2990        base_query = (
2991            f"SELECT TOP 1 {dt_col_name}\n"
2992            f"FROM {src_name}\n"
2993            f"ORDER BY {dt_col_name} {ASC_or_DESC}"
2994        )
2995    elif self.flavor == 'oracle':
2996        base_query = (
2997            "SELECT * FROM (\n"
2998            f"    SELECT {dt_col_name}\n"
2999            f"    FROM {src_name}\n"
3000            f"    ORDER BY {dt_col_name} {ASC_or_DESC}\n"
3001            ") WHERE ROWNUM = 1"
3002        )
3003
3004    ### NOTE: MariaDB has an optimizer bug where `ORDER BY <dt> DESC/ASC LIMIT 1` against a
3005    ### `RANGE COLUMNS` partitioned table combined with a `WHERE` clause performs a partition
3006    ### index scan that stops early and returns zero rows (observed on MariaDB 12.x). The
3007    ### equivalent `MIN`/`MAX` aggregate scans the pruned partitions correctly, so use it for
3008    ### the bounds on partitioned MariaDB tables instead.
3009    if self.flavor == 'mariadb' and not remote and self._should_partition(pipe):
3010        agg_func = "MAX" if newest else "MIN"
3011        base_query = (
3012            f"SELECT {agg_func}({dt_col_name}) AS {dt_col_name}\n"
3013            f"FROM {src_name}"
3014        )
3015
3016    query = wrap_query_with_cte(src_query, base_query, flavor)
3017
3018    try:
3019        db_time = self.value(query, silent=True, debug=debug)
3020
3021        ### No datetime could be found.
3022        if db_time is None:
3023            return None
3024        ### sqlite returns str.
3025        if isinstance(db_time, str):
3026            dateutil_parser = mrsm.attempt_import('dateutil.parser')
3027            st = dateutil_parser.parse(db_time)
3028        ### Do nothing if a datetime object is returned.
3029        elif isinstance(db_time, datetime):
3030            if hasattr(db_time, 'to_pydatetime'):
3031                st = db_time.to_pydatetime()
3032            else:
3033                st = db_time
3034        ### Sometimes the datetime is actually a date.
3035        elif isinstance(db_time, date):
3036            st = datetime.combine(db_time, datetime.min.time())
3037        ### Adding support for an integer datetime axis.
3038        elif 'int' in str(type(db_time)).lower():
3039            st = int(db_time)
3040        ### Convert pandas timestamp to Python datetime.
3041        else:
3042            st = db_time.to_pydatetime()
3043
3044        sync_time = st
3045
3046    except Exception as e:
3047        sync_time = None
3048        warn(str(e))
3049
3050    return sync_time

Get a Pipe's most recent datetime value.

Parameters
  • pipe (mrsm.Pipe): The pipe to get the sync time for.
  • params (Optional[Dict[str, Any]], default None): Optional params dictionary to build the WHERE clause. See meerschaum.utils.sql.build_where.
  • newest (bool, default True): If True, get the most recent datetime (honoring params). If False, get the oldest datetime (ASC instead of DESC).
  • remote (bool, default False): If True, return the sync time for the remote fetch definition.
Returns
  • A datetime object (or int if using an integer axis) if the pipe exists, otherwise None.
def pipe_exists(self, pipe: meerschaum.Pipe, debug: bool = False) -> bool:
3053def pipe_exists(
3054    self,
3055    pipe: mrsm.Pipe,
3056    debug: bool = False
3057) -> bool:
3058    """
3059    Check that a Pipe's table exists.
3060
3061    Parameters
3062    ----------
3063    pipe: mrsm.Pipe:
3064        The pipe to check.
3065
3066    debug: bool, default False
3067        Verbosity toggle.
3068
3069    Returns
3070    -------
3071    A `bool` corresponding to whether a pipe's table exists.
3072
3073    """
3074    from meerschaum.utils.sql import table_exists
3075    exists = table_exists(
3076        pipe.target,
3077        self,
3078        schema=self.get_pipe_schema(pipe),
3079        debug=debug,
3080    )
3081    if debug:
3082        dprint(f"{pipe} " + ('exists.' if exists else 'does not exist.'))
3083    return exists

Check that a Pipe's table exists.

Parameters
  • pipe (mrsm.Pipe:): The pipe to check.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A bool corresponding to whether a pipe's table exists.
def get_pipe_rowcount( self, pipe: meerschaum.Pipe, begin: Union[datetime.datetime, int, NoneType] = None, end: Union[datetime.datetime, int, NoneType] = None, params: Optional[Dict[str, Any]] = None, remote: bool = False, debug: bool = False) -> Optional[int]:
3086def get_pipe_rowcount(
3087    self,
3088    pipe: mrsm.Pipe,
3089    begin: Union[datetime, int, None] = None,
3090    end: Union[datetime, int, None] = None,
3091    params: Optional[Dict[str, Any]] = None,
3092    remote: bool = False,
3093    debug: bool = False
3094) -> Union[int, None]:
3095    """
3096    Get the rowcount for a pipe in accordance with given parameters.
3097
3098    Parameters
3099    ----------
3100    pipe: mrsm.Pipe
3101        The pipe to query with.
3102
3103    begin: Union[datetime, int, None], default None
3104        The begin datetime value.
3105
3106    end: Union[datetime, int, None], default None
3107        The end datetime value.
3108
3109    params: Optional[Dict[str, Any]], default None
3110        See `meerschaum.utils.sql.build_where`.
3111
3112    remote: bool, default False
3113        If `True`, get the rowcount for the remote table.
3114
3115    debug: bool, default False
3116        Verbosity toggle.
3117
3118    Returns
3119    -------
3120    An `int` for the number of rows if the `pipe` exists, otherwise `None`.
3121
3122    """
3123    from meerschaum.utils.sql import dateadd_str, sql_item_name, wrap_query_with_cte, build_where
3124    from meerschaum.connectors.sql._fetch import get_pipe_query
3125    from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type
3126    if remote:
3127        msg = f"'fetch:definition' must be an attribute of {pipe} to get a remote rowcount."
3128        if 'fetch' not in pipe.parameters:
3129            error(msg)
3130            return None
3131        if 'definition' not in pipe.parameters['fetch']:
3132            error(msg)
3133            return None
3134    elif not pipe.exists(debug=debug):
3135        return None
3136
3137    flavor = self.flavor if not remote else pipe.connector.flavor
3138    conn = self if not remote else pipe.connector
3139    _pipe_name = sql_item_name(pipe.target, flavor, self.get_pipe_schema(pipe))
3140    dt_col = pipe.columns.get('datetime', None)
3141    dt_typ = pipe.dtypes.get(dt_col, 'datetime') if dt_col else None
3142    dt_db_type = get_db_type_from_pd_type(dt_typ, flavor) if dt_typ else None
3143    if not dt_col:
3144        dt_col = pipe.guess_datetime()
3145        dt_name = sql_item_name(dt_col, flavor, None) if dt_col else None
3146        is_guess = True
3147    else:
3148        dt_col = pipe.get_columns('datetime')
3149        dt_name = sql_item_name(dt_col, flavor, None)
3150        is_guess = False
3151
3152    if begin is not None or end is not None:
3153        if is_guess:
3154            if dt_col is None:
3155                warn(
3156                    f"No datetime could be determined for {pipe}."
3157                    + "\n    Ignoring begin and end...",
3158                    stack=False,
3159                )
3160                begin, end = None, None
3161            else:
3162                warn(
3163                    f"A datetime wasn't specified for {pipe}.\n"
3164                    + f"    Using column \"{dt_col}\" for datetime bounds...",
3165                    stack=False,
3166                )
3167
3168
3169    _datetime_name = sql_item_name(dt_col, flavor)
3170    _cols_names = [
3171        sql_item_name(col, flavor)
3172        for col in set(
3173            (
3174                [dt_col]
3175                if dt_col
3176                else []
3177            ) + (
3178                []
3179                if params is None
3180                else list(params.keys())
3181            )
3182        )
3183    ]
3184    if not _cols_names:
3185        _cols_names = ['*']
3186
3187    src = (
3188        f"SELECT {', '.join(_cols_names)}\nFROM {_pipe_name}"
3189        if not remote
3190        else get_pipe_query(pipe)
3191    )
3192    parent_query = f"SELECT COUNT(*)\nFROM {sql_item_name('src', flavor)}"
3193    query = wrap_query_with_cte(src, parent_query, flavor)
3194    if begin is not None or end is not None:
3195        query += "\nWHERE"
3196    if begin is not None:
3197        query += (
3198            f"\n    {dt_name} >= "
3199            + dateadd_str(flavor, datepart='minute', number=0, begin=begin, db_type=dt_db_type)
3200        )
3201    if end is not None and begin is not None:
3202        query += "\n    AND"
3203    if end is not None:
3204        query += (
3205            f"\n    {dt_name} <  "
3206            + dateadd_str(flavor, datepart='minute', number=0, begin=end, db_type=dt_db_type)
3207        )
3208    if params is not None:
3209        existing_cols = pipe.get_columns_types(debug=debug)
3210        valid_params = {k: v for k, v in params.items() if k in existing_cols}
3211        if valid_params:
3212            query += build_where(valid_params, conn).replace('WHERE', (
3213                'AND' if (begin is not None or end is not None)
3214                    else 'WHERE'
3215                )
3216            )
3217
3218    result = conn.value(query, debug=debug, silent=True)
3219    try:
3220        return int(result)
3221    except Exception:
3222        return None

Get the rowcount for a pipe in accordance with given parameters.

Parameters
  • pipe (mrsm.Pipe): The pipe to query with.
  • begin (Union[datetime, int, None], default None): The begin datetime value.
  • end (Union[datetime, int, None], default None): The end datetime value.
  • params (Optional[Dict[str, Any]], default None): See meerschaum.utils.sql.build_where.
  • remote (bool, default False): If True, get the rowcount for the remote table.
  • debug (bool, default False): Verbosity toggle.
Returns
  • An int for the number of rows if the pipe exists, otherwise None.
def drop_pipe( self, pipe: meerschaum.Pipe, debug: bool = False, **kw) -> Tuple[bool, str]:
3225def drop_pipe(
3226    self,
3227    pipe: mrsm.Pipe,
3228    debug: bool = False,
3229    **kw
3230) -> SuccessTuple:
3231    """
3232    Drop a pipe's tables but maintain its registration.
3233
3234    Parameters
3235    ----------
3236    pipe: mrsm.Pipe
3237        The pipe to drop.
3238
3239    Returns
3240    -------
3241    A `SuccessTuple` indicated success.
3242    """
3243    from meerschaum.utils.sql import table_exists, sql_item_name, DROP_IF_EXISTS_FLAVORS
3244    success = True
3245    target = pipe.target
3246    schema = self.get_pipe_schema(pipe)
3247    target_name = (
3248        sql_item_name(target, self.flavor, schema)
3249    )
3250    if table_exists(target, self, schema=schema, debug=debug):
3251        if_exists_str = "IF EXISTS" if self.flavor in DROP_IF_EXISTS_FLAVORS else ""
3252        success = self.exec(
3253            f"DROP TABLE {if_exists_str} {target_name}", silent=True, debug=debug
3254        ) is not None
3255
3256    ### Drop any MSSQL partition scheme + function the table referenced (no-op otherwise).
3257    if success:
3258        cleanup_queries = self._get_partition_cleanup_queries(pipe)
3259        if cleanup_queries:
3260            self.exec_queries(cleanup_queries, break_on_error=False, silent=True, debug=debug)
3261
3262    msg = "Success" if success else f"Failed to drop {pipe}."
3263    return success, msg

Drop a pipe's tables but maintain its registration.

Parameters
  • pipe (mrsm.Pipe): The pipe to drop.
Returns
  • A SuccessTuple indicated success.
def clear_pipe( self, pipe: meerschaum.Pipe, begin: Union[datetime.datetime, int, NoneType] = None, end: Union[datetime.datetime, int, NoneType] = None, params: Optional[Dict[str, Any]] = None, debug: bool = False, **kw) -> Tuple[bool, str]:
3266def clear_pipe(
3267    self,
3268    pipe: mrsm.Pipe,
3269    begin: Union[datetime, int, None] = None,
3270    end: Union[datetime, int, None] = None,
3271    params: Optional[Dict[str, Any]] = None,
3272    debug: bool = False,
3273    **kw
3274) -> SuccessTuple:
3275    """
3276    Delete a pipe's data within a bounded or unbounded interval without dropping the table.
3277
3278    Parameters
3279    ----------
3280    pipe: mrsm.Pipe
3281        The pipe to clear.
3282        
3283    begin: Union[datetime, int, None], default None
3284        Beginning datetime. Inclusive.
3285
3286    end: Union[datetime, int, None], default None
3287         Ending datetime. Exclusive.
3288
3289    params: Optional[Dict[str, Any]], default None
3290         See `meerschaum.utils.sql.build_where`.
3291
3292    """
3293    if not pipe.exists(debug=debug):
3294        return True, f"{pipe} does not exist, so nothing was cleared."
3295
3296    from meerschaum.utils.sql import sql_item_name, build_where, dateadd_str
3297    from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type
3298    pipe_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
3299
3300    dt_col = pipe.columns.get('datetime', None)
3301    dt_typ = pipe.dtypes.get(dt_col, 'datetime') if dt_col else None
3302    dt_db_type = get_db_type_from_pd_type(dt_typ, self.flavor) if dt_typ else None
3303    if not pipe.columns.get('datetime', None):
3304        dt_col = pipe.guess_datetime()
3305        dt_name = sql_item_name(dt_col, self.flavor, None) if dt_col else None
3306        is_guess = True
3307    else:
3308        dt_col = pipe.get_columns('datetime')
3309        dt_name = sql_item_name(dt_col, self.flavor, None)
3310        is_guess = False
3311
3312    if begin is not None or end is not None:
3313        if is_guess:
3314            if dt_col is None:
3315                warn(
3316                    f"No datetime could be determined for {pipe}."
3317                    + "\n    Ignoring datetime bounds...",
3318                    stack=False,
3319                )
3320                begin, end = None, None
3321            else:
3322                warn(
3323                    f"A datetime wasn't specified for {pipe}.\n"
3324                    + f"    Using column \"{dt_col}\" for datetime bounds...",
3325                    stack=False,
3326                )
3327
3328    valid_params = {}
3329    if params is not None:
3330        existing_cols = pipe.get_columns_types(debug=debug)
3331        valid_params = {k: v for k, v in params.items() if k in existing_cols}
3332    clear_query = (
3333        f"DELETE FROM {pipe_name}\nWHERE 1 = 1\n"
3334        + ('\n    AND ' + build_where(valid_params, self, with_where=False) if valid_params else '')
3335        + (
3336            (
3337                f'\n    AND {dt_name} >= '
3338                + dateadd_str(self.flavor, 'day', 0, begin, db_type=dt_db_type)
3339            )
3340            if begin is not None
3341            else ''
3342        ) + (
3343            (
3344                f'\n    AND {dt_name} <  '
3345                + dateadd_str(self.flavor, 'day', 0, end, db_type=dt_db_type)
3346            )
3347            if end is not None
3348            else ''
3349        )
3350    )
3351    success = self.exec(clear_query, silent=True, debug=debug) is not None
3352    msg = "Success" if success else f"Failed to clear {pipe}."
3353    return success, msg

Delete a pipe's data within a bounded or unbounded interval without dropping the table.

Parameters
  • pipe (mrsm.Pipe): The pipe to clear.
  • begin (Union[datetime, int, None], default None): Beginning datetime. Inclusive.
  • end (Union[datetime, int, None], default None): Ending datetime. Exclusive.
  • params (Optional[Dict[str, Any]], default None): See meerschaum.utils.sql.build_where.
def deduplicate_pipe( self, pipe: meerschaum.Pipe, begin: Union[datetime.datetime, int, NoneType] = None, end: Union[datetime.datetime, int, NoneType] = None, params: Optional[Dict[str, Any]] = None, debug: bool = False, **kwargs: Any) -> Tuple[bool, str]:
3996def deduplicate_pipe(
3997    self,
3998    pipe: mrsm.Pipe,
3999    begin: Union[datetime, int, None] = None,
4000    end: Union[datetime, int, None] = None,
4001    params: Optional[Dict[str, Any]] = None,
4002    debug: bool = False,
4003    **kwargs: Any
4004) -> SuccessTuple:
4005    """
4006    Delete duplicate values within a pipe's table.
4007
4008    Parameters
4009    ----------
4010    pipe: mrsm.Pipe
4011        The pipe whose table to deduplicate.
4012
4013    begin: Union[datetime, int, None], default None
4014        If provided, only deduplicate values greater than or equal to this value.
4015
4016    end: Union[datetime, int, None], default None
4017        If provided, only deduplicate values less than this value.
4018
4019    params: Optional[Dict[str, Any]], default None
4020        If provided, further limit deduplication to values which match this query dictionary.
4021
4022    debug: bool, default False
4023        Verbosity toggle.
4024
4025    Returns
4026    -------
4027    A `SuccessTuple` indicating success.
4028    """
4029    from meerschaum.utils.sql import (
4030        sql_item_name,
4031        get_rename_table_queries,
4032        DROP_IF_EXISTS_FLAVORS,
4033        get_create_table_query,
4034        format_cte_subquery,
4035        get_null_replacement,
4036    )
4037    from meerschaum.utils.misc import generate_password, flatten_list
4038
4039    pipe_table_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
4040
4041    if not pipe.exists(debug=debug):
4042        return False, f"Table {pipe_table_name} does not exist."
4043
4044    dt_col = pipe.columns.get('datetime', None)
4045    cols_types = pipe.get_columns_types(debug=debug)
4046    existing_cols = pipe.get_columns_types(debug=debug)
4047
4048    get_rowcount_query = f"SELECT COUNT(*) FROM {pipe_table_name}"
4049    old_rowcount = self.value(get_rowcount_query, debug=debug)
4050    if old_rowcount is None:
4051        return False, f"Failed to get rowcount for table {pipe_table_name}."
4052
4053    ### Non-datetime indices that in fact exist.
4054    indices = [
4055        col
4056        for key, col in pipe.columns.items()
4057        if col and col != dt_col and col in cols_types
4058    ]
4059    indices_names = [sql_item_name(index_col, self.flavor, None) for index_col in indices]
4060    existing_cols_names = [sql_item_name(col, self.flavor, None) for col in existing_cols]
4061    duplicate_row_number_name = sql_item_name('dup_row_num', self.flavor, None)
4062    previous_row_number_name = sql_item_name('prev_row_num', self.flavor, None)
4063
4064    index_list_str = (
4065        sql_item_name(dt_col, self.flavor, None)
4066        if dt_col
4067        else ''
4068    )
4069    index_list_str_ordered = (
4070        (
4071            sql_item_name(dt_col, self.flavor, None) + " DESC"
4072        )
4073        if dt_col
4074        else ''
4075    )
4076    if indices:
4077        index_list_str += ', ' + ', '.join(indices_names)
4078        index_list_str_ordered += ', ' + ', '.join(indices_names)
4079    if index_list_str.startswith(','):
4080        index_list_str = index_list_str.lstrip(',').lstrip()
4081    if index_list_str_ordered.startswith(','):
4082        index_list_str_ordered = index_list_str_ordered.lstrip(',').lstrip()
4083
4084    cols_list_str = ', '.join(existing_cols_names)
4085
4086    try:
4087        ### NOTE: MySQL 5 and below does not support window functions (ROW_NUMBER()).
4088        is_old_mysql = (
4089            self.flavor in ('mysql', 'mariadb')
4090            and
4091            int(self.db_version.split('.')[0]) < 8
4092        )
4093    except Exception:
4094        is_old_mysql = False
4095
4096    src_query = f"""
4097        SELECT
4098            {cols_list_str},
4099            ROW_NUMBER() OVER (
4100                PARTITION BY
4101                {index_list_str}
4102                ORDER BY {index_list_str_ordered}
4103            ) AS {duplicate_row_number_name}
4104        FROM {pipe_table_name}
4105    """
4106    duplicates_cte_subquery = format_cte_subquery(
4107        src_query,
4108        self.flavor,
4109        sub_name = 'src',
4110        cols_to_select = cols_list_str,
4111    ) + f"""
4112        WHERE {duplicate_row_number_name} = 1
4113        """
4114    old_mysql_query = (
4115        f"""
4116        SELECT
4117            {index_list_str}
4118        FROM (
4119          SELECT
4120            {index_list_str},
4121            IF(
4122                @{previous_row_number_name} <> {index_list_str.replace(', ', ' + ')},
4123                @{duplicate_row_number_name} := 0,
4124                @{duplicate_row_number_name}
4125            ),
4126            @{previous_row_number_name} := {index_list_str.replace(', ', ' + ')},
4127            @{duplicate_row_number_name} := @{duplicate_row_number_name} + 1 AS """
4128        + f"""{duplicate_row_number_name}
4129          FROM
4130            {pipe_table_name},
4131            (
4132                SELECT @{duplicate_row_number_name} := 0
4133            ) AS {duplicate_row_number_name},
4134            (
4135                SELECT @{previous_row_number_name} := '{get_null_replacement('str', 'mysql')}'
4136            ) AS {previous_row_number_name}
4137          ORDER BY {index_list_str_ordered}
4138        ) AS t
4139        WHERE {duplicate_row_number_name} = 1
4140        """
4141    )
4142    if is_old_mysql:
4143        duplicates_cte_subquery = old_mysql_query
4144
4145    session_id = generate_password(3)
4146
4147    dedup_table = self.get_temporary_target(pipe.target, transact_id=session_id, label='dedup')
4148    temp_old_table = self.get_temporary_target(pipe.target, transact_id=session_id, label='old')
4149    temp_old_table_name = sql_item_name(temp_old_table, self.flavor, self.get_pipe_schema(pipe))
4150
4151    create_temporary_table_query = get_create_table_query(
4152        duplicates_cte_subquery,
4153        dedup_table,
4154        self.flavor,
4155    ) + f"""
4156    ORDER BY {index_list_str_ordered}
4157    """
4158    if_exists_str = "IF EXISTS" if self.flavor in DROP_IF_EXISTS_FLAVORS else ""
4159    alter_queries = flatten_list([
4160        get_rename_table_queries(
4161            pipe.target,
4162            temp_old_table,
4163            self.flavor,
4164            schema=self.get_pipe_schema(pipe),
4165        ),
4166        get_rename_table_queries(
4167            dedup_table,
4168            pipe.target,
4169            self.flavor,
4170            schema=None,
4171            new_schema=self.get_pipe_schema(pipe),
4172        ),
4173        f"DROP TABLE {if_exists_str} {temp_old_table_name}",
4174    ])
4175
4176    self._log_temporary_tables_creation(temp_old_table, create=(not pipe.temporary), debug=debug)
4177    create_temporary_result = self.execute(create_temporary_table_query, debug=debug)
4178    if create_temporary_result is None:
4179        return False, f"Failed to deduplicate table {pipe_table_name}."
4180
4181    results = self.exec_queries(
4182        alter_queries,
4183        break_on_error=True,
4184        rollback=True,
4185        debug=debug,
4186    )
4187
4188    fail_query = None
4189    for result, query in zip(results, alter_queries):
4190        if result is None:
4191            fail_query = query
4192            break
4193    success = fail_query is None
4194
4195    new_rowcount = (
4196        self.value(get_rowcount_query, debug=debug)
4197        if success
4198        else None
4199    )
4200
4201    msg = (
4202        (
4203            f"Successfully deduplicated table {pipe_table_name}"
4204            + (
4205                f"\nfrom {old_rowcount:,} to {new_rowcount:,} rows"
4206                if old_rowcount != new_rowcount
4207                else ''
4208            ) + '.'
4209        )
4210        if success
4211        else f"Failed to execute query:\n{fail_query}"
4212    )
4213    return success, msg

Delete duplicate values within a pipe's table.

Parameters
  • pipe (mrsm.Pipe): The pipe whose table to deduplicate.
  • begin (Union[datetime, int, None], default None): If provided, only deduplicate values greater than or equal to this value.
  • end (Union[datetime, int, None], default None): If provided, only deduplicate values less than this value.
  • params (Optional[Dict[str, Any]], default None): If provided, further limit deduplication to values which match this query dictionary.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A SuccessTuple indicating success.
def get_pipe_table( self, pipe: meerschaum.Pipe, debug: bool = False) -> Optional[sqlalchemy.sql.schema.Table]:
3356def get_pipe_table(
3357    self,
3358    pipe: mrsm.Pipe,
3359    debug: bool = False,
3360) -> Union['sqlalchemy.Table', None]:
3361    """
3362    Return the `sqlalchemy.Table` object for a `mrsm.Pipe`.
3363
3364    Parameters
3365    ----------
3366    pipe: mrsm.Pipe:
3367        The pipe in question.
3368
3369    Returns
3370    -------
3371    A `sqlalchemy.Table` object. 
3372
3373    """
3374    from meerschaum.utils.sql import get_sqlalchemy_table
3375    if not pipe.exists(debug=debug):
3376        return None
3377
3378    return get_sqlalchemy_table(
3379        pipe.target,
3380        connector=self,
3381        schema=self.get_pipe_schema(pipe),
3382        debug=debug,
3383        refresh=True,
3384    )

Return the sqlalchemy.Table object for a mrsm.Pipe.

Parameters
  • pipe (mrsm.Pipe:): The pipe in question.
Returns
  • A sqlalchemy.Table object.
def get_pipe_columns_types( self, pipe: meerschaum.Pipe, debug: bool = False) -> Dict[str, str]:
3387def get_pipe_columns_types(
3388    self,
3389    pipe: mrsm.Pipe,
3390    debug: bool = False,
3391) -> Dict[str, str]:
3392    """
3393    Get the pipe's columns and types.
3394
3395    Parameters
3396    ----------
3397    pipe: mrsm.Pipe:
3398        The pipe to get the columns for.
3399
3400    Returns
3401    -------
3402    A dictionary of columns names (`str`) and types (`str`).
3403
3404    Examples
3405    --------
3406    >>> conn.get_pipe_columns_types(pipe)
3407    {
3408      'dt': 'TIMESTAMP WITHOUT TIMEZONE',
3409      'id': 'BIGINT',
3410      'val': 'DOUBLE PRECISION',
3411    }
3412    >>> 
3413    """
3414    from meerschaum.utils.sql import get_table_cols_types
3415    if not pipe.exists(debug=debug):
3416        return {}
3417
3418    if self.flavor not in ('oracle', 'mysql', 'mariadb', 'sqlite', 'geopackage'):
3419        return get_table_cols_types(
3420            pipe.target,
3421            self,
3422            flavor=self.flavor,
3423            schema=self.get_pipe_schema(pipe),
3424            debug=debug,
3425        )
3426
3427    if debug:
3428        dprint(f"Fetching columns_types for {pipe} with via SQLAlchemy table.")
3429
3430    table_columns = {}
3431    try:
3432        pipe_table = self.get_pipe_table(pipe, debug=debug)
3433        if pipe_table is None:
3434            return {}
3435
3436        if debug:
3437            dprint("Found columns:")
3438            mrsm.pprint(dict(pipe_table.columns))
3439
3440        for col in pipe_table.columns:
3441            table_columns[str(col.name)] = str(col.type)
3442    except Exception as e:
3443        traceback.print_exc()
3444        warn(e)
3445        table_columns = {}
3446
3447    return table_columns

Get the pipe's columns and types.

Parameters
  • pipe (mrsm.Pipe:): The pipe to get the columns for.
Returns
  • A dictionary of columns names (str) and types (str).
Examples
>>> conn.get_pipe_columns_types(pipe)
{
  'dt': 'TIMESTAMP WITHOUT TIMEZONE',
  'id': 'BIGINT',
  'val': 'DOUBLE PRECISION',
}
>>>
def get_to_sql_dtype( self, pipe: meerschaum.Pipe, df: "'pd.DataFrame'", update_dtypes: bool = True) -> "Dict[str, 'sqlalchemy.sql.visitors.TraversibleType']":
3942def get_to_sql_dtype(
3943    self,
3944    pipe: 'mrsm.Pipe',
3945    df: 'pd.DataFrame',
3946    update_dtypes: bool = True,
3947) -> Dict[str, 'sqlalchemy.sql.visitors.TraversibleType']:
3948    """
3949    Given a pipe and DataFrame, return the `dtype` dictionary for `to_sql()`.
3950
3951    Parameters
3952    ----------
3953    pipe: mrsm.Pipe
3954        The pipe which may contain a `dtypes` parameter.
3955
3956    df: pd.DataFrame
3957        The DataFrame to be pushed via `to_sql()`.
3958
3959    update_dtypes: bool, default True
3960        If `True`, patch the pipe's dtypes onto the DataFrame's dtypes.
3961
3962    Returns
3963    -------
3964    A dictionary with `sqlalchemy` datatypes.
3965
3966    Examples
3967    --------
3968    >>> import pandas as pd
3969    >>> import meerschaum as mrsm
3970    >>> 
3971    >>> conn = mrsm.get_connector('sql:memory')
3972    >>> df = pd.DataFrame([{'a': {'b': 1}}])
3973    >>> pipe = mrsm.Pipe('a', 'b', dtypes={'a': 'json'})
3974    >>> get_to_sql_dtype(pipe, df)
3975    {'a': <class 'sqlalchemy.sql.sqltypes.JSON'>}
3976    """
3977    from meerschaum.utils.dataframe import get_special_cols
3978    from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type
3979    df_dtypes = {
3980        col: str(typ)
3981        for col, typ in df.dtypes.items()
3982    }
3983    special_cols = get_special_cols(df)
3984    df_dtypes.update(special_cols)
3985
3986    if update_dtypes:
3987        df_dtypes.update(pipe.dtypes)
3988
3989    return {
3990        col: get_db_type_from_pd_type(typ, self.flavor, as_sqlalchemy=True)
3991        for col, typ in df_dtypes.items()
3992        if col and typ
3993    }

Given a pipe and DataFrame, return the dtype dictionary for to_sql().

Parameters
  • pipe (mrsm.Pipe): The pipe which may contain a dtypes parameter.
  • df (pd.DataFrame): The DataFrame to be pushed via to_sql().
  • update_dtypes (bool, default True): If True, patch the pipe's dtypes onto the DataFrame's dtypes.
Returns
  • A dictionary with sqlalchemy datatypes.
Examples
>>> import pandas as pd
>>> import meerschaum as mrsm
>>> 
>>> conn = mrsm.get_connector('sql:memory')
>>> df = pd.DataFrame([{'a': {'b': 1}}])
>>> pipe = mrsm.Pipe('a', 'b', dtypes={'a': 'json'})
>>> get_to_sql_dtype(pipe, df)
{'a': <class 'sqlalchemy.sql.sqltypes.JSON'>}
def get_pipe_schema(self, pipe: meerschaum.Pipe) -> Optional[str]:
4216def get_pipe_schema(self, pipe: mrsm.Pipe) -> Union[str, None]:
4217    """
4218    Return the schema to use for this pipe.
4219    First check `pipe.parameters['schema']`, then check `self.schema`.
4220
4221    Parameters
4222    ----------
4223    pipe: mrsm.Pipe
4224        The pipe which may contain a configured schema.
4225
4226    Returns
4227    -------
4228    A schema string or `None` if nothing is configured.
4229    """
4230    if self.flavor in ('sqlite', 'geopackage'):
4231        return self.schema
4232    return pipe.parameters.get('schema', self.schema)

Return the schema to use for this pipe. First check pipe.parameters['schema'], then check self.schema.

Parameters
  • pipe (mrsm.Pipe): The pipe which may contain a configured schema.
Returns
  • A schema string or None if nothing is configured.
def create_pipe_table_from_df( self, pipe: meerschaum.Pipe, df: "'pd.DataFrame'", debug: bool = False) -> Tuple[bool, str]:
1732def create_pipe_table_from_df(
1733    self,
1734    pipe: mrsm.Pipe,
1735    df: 'pd.DataFrame',
1736    debug: bool = False,
1737) -> mrsm.SuccessTuple:
1738    """
1739    Create a pipe's table from its configured dtypes and an incoming dataframe.
1740    """
1741    from meerschaum.utils.dataframe import get_special_cols
1742    from meerschaum.utils.sql import (
1743        get_create_table_queries,
1744        sql_item_name,
1745        get_create_schema_if_not_exists_queries,
1746    )
1747    from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type
1748    if self.flavor == 'geopackage':
1749        init_success, init_msg = self._init_geopackage_pipe(df, pipe, debug=debug)
1750        if not init_success:
1751            return init_success, init_msg
1752
1753    primary_key = pipe.columns.get('primary', None)
1754    primary_key_typ = (
1755        pipe.dtypes.get(primary_key, str(df.dtypes.get(primary_key, 'int')))
1756        if primary_key
1757        else None
1758    )
1759    primary_key_db_type = (
1760        get_db_type_from_pd_type(primary_key_typ, self.flavor)
1761        if primary_key
1762        else None
1763    )
1764    dt_col = pipe.columns.get('datetime', None)
1765    new_dtypes = {
1766        **{
1767            col: str(typ)
1768            for col, typ in df.dtypes.items()
1769        },
1770        **{
1771            col: str(df.dtypes.get(col, 'int'))
1772            for col_ix, col in pipe.columns.items()
1773            if col and col_ix != 'primary'
1774        },
1775        **get_special_cols(df),
1776        **pipe.dtypes
1777    }
1778    autoincrement = (
1779        pipe.parameters.get('autoincrement', False)
1780        or (primary_key and primary_key not in new_dtypes)
1781    )
1782    if autoincrement:
1783        _ = new_dtypes.pop(primary_key, None)
1784
1785    schema = self.get_pipe_schema(pipe)
1786
1787    ### When supported (TimescaleDB 2.21+), create the hypertable declaratively via
1788    ### `CREATE TABLE ... WITH (tsdb.hypertable, ...)`. Fall back to a plain `CREATE TABLE`
1789    ### (and the `create_hypertable()` call in `get_create_index_queries`) if it fails.
1790    hypertable = (
1791        self.flavor in ('timescaledb', 'timescaledb-ha')
1792        and pipe.parameters.get('hypertable', True)
1793        and dt_col is not None
1794    )
1795
1796    ### Use the declarative `CREATE TABLE ... WITH (tsdb.hypertable, ...)` path only when Hypercore
1797    ### is enabled (the default). Declarative creation enables the columnstore (via the
1798    ### `segmentby`/`orderby` options) AND makes TimescaleDB auto-install a columnstore policy —
1799    ### exactly the Hypercore behavior we want. With `hypercore=False`, fall back to a plain table
1800    ### plus the `create_hypertable()` call during index creation, which adds NO columnstore policy,
1801    ### keeping `hypercore` a true opt-out (a plain row-store hypertable).
1802    hypercore = hypertable and pipe.parameters.get('hypercore', True)
1803    hypertable_chunk_interval = None
1804    hypertable_segmentby = None
1805    hypertable_orderby = None
1806    if hypercore:
1807        chunk_interval = pipe.get_chunk_interval(debug=debug)
1808        hypertable_chunk_interval = (
1809            f'{chunk_interval}'
1810            if isinstance(chunk_interval, int)
1811            else f'{int(chunk_interval.total_seconds() / 60)} minutes'
1812        )
1813        _compress_settings = self._get_compress_settings(pipe)
1814        hypertable_segmentby = _compress_settings['segmentby'] or None
1815        hypertable_orderby = _compress_settings['orderby'] or None
1816
1817    ### Native range partitioning (non-TimescaleDB flavors); a no-op column for others.
1818    partition_by_column = self._get_partition_column(pipe)
1819    ### MySQL/MariaDB require the initial partitions declared inline at `CREATE TABLE`
1820    ### (an empty RANGE-partitioned table is invalid); compute them from the creation df.
1821    partition_bounds = (
1822        self._get_initial_partition_bounds(pipe, df, debug=debug)
1823        if (partition_by_column is not None and self.flavor in ('mysql', 'mariadb'))
1824        else None
1825    )
1826    ### A MySQL RANGE table needs at least one inline partition; if the creation df has no
1827    ### datetime values, fall back to a plain table (rare — `is_new` normally implies rows).
1828    if partition_by_column is not None and self.flavor in ('mysql', 'mariadb') and not partition_bounds:
1829        partition_by_column = None
1830
1831    ### MSSQL partitions via a function + scheme created before the table; its clustered index is
1832    ### placed on the scheme (passed as `partition_scheme_name`).
1833    partition_scheme_name = None
1834    partition_creation_queries = []
1835    if partition_by_column is not None and self.flavor == 'mssql':
1836        partition_scheme_name = self._partition_scheme_name(pipe)
1837        partition_creation_queries = self._get_mssql_partition_creation_queries(
1838            pipe, df, debug=debug
1839        )
1840
1841    def _build_create_table_queries(_hypertable_chunk_interval):
1842        _queries = get_create_table_queries(
1843            new_dtypes,
1844            pipe.target,
1845            self.flavor,
1846            schema=schema,
1847            primary_key=primary_key,
1848            primary_key_db_type=primary_key_db_type,
1849            datetime_column=dt_col,
1850            hypertable_chunk_interval=_hypertable_chunk_interval,
1851            hypertable_segmentby=(hypertable_segmentby if _hypertable_chunk_interval else None),
1852            hypertable_orderby=(hypertable_orderby if _hypertable_chunk_interval else None),
1853            partition_by_column=partition_by_column,
1854            partition_bounds=partition_bounds,
1855            partition_scheme_name=partition_scheme_name,
1856        )
1857        if partition_creation_queries:
1858            _queries = partition_creation_queries + _queries
1859        if schema:
1860            _queries = (
1861                get_create_schema_if_not_exists_queries(schema, self.flavor)
1862                + _queries
1863            )
1864        return _queries
1865
1866    create_table_queries = _build_create_table_queries(hypertable_chunk_interval)
1867    success = all(
1868        self.exec_queries(
1869            create_table_queries,
1870            break_on_error=True,
1871            rollback=True,
1872            silent=hypercore,
1873            debug=debug,
1874        )
1875    )
1876    if not success and hypercore:
1877        ### Declarative hypertable syntax unsupported; retry as a plain table.
1878        ### `create_hypertable()` runs later during index creation.
1879        create_table_queries = _build_create_table_queries(None)
1880        success = all(
1881            self.exec_queries(create_table_queries, break_on_error=True, rollback=True, debug=debug)
1882        )
1883    ### Declarative Hypercore creation auto-installs a columnstore policy, which needs an
1884    ### `integer_now` function on an integer axis.
1885    if success and hypercore and not self.set_integer_now_func(pipe, debug=debug):
1886        warn(
1887            f"Could not register an `integer_now` function for {pipe}; "
1888            "its columnstore policy will fail on every run.",
1889            stack=False,
1890        )
1891
1892    target_name = sql_item_name(pipe.target, schema=self.get_pipe_schema(pipe), flavor=self.flavor)
1893    msg = (
1894        "Success"
1895        if success
1896        else f"Failed to create {target_name}."
1897    )
1898    if success and self.flavor == 'geopackage':
1899        return self._init_geopackage_pipe(df, pipe, debug=debug)
1900
1901    return success, msg

Create a pipe's table from its configured dtypes and an incoming dataframe.

def get_pipe_columns_indices( self, pipe: meerschaum.Pipe, debug: bool = False) -> Dict[str, List[Dict[str, str]]]:
3450def get_pipe_columns_indices(
3451    self,
3452    pipe: mrsm.Pipe,
3453    debug: bool = False,
3454) -> Dict[str, List[Dict[str, str]]]:
3455    """
3456    Return a dictionary mapping columns to the indices created on those columns.
3457
3458    Parameters
3459    ----------
3460    pipe: mrsm.Pipe
3461        The pipe to be queried against.
3462
3463    Returns
3464    -------
3465    A dictionary mapping columns names to lists of dictionaries.
3466    The dictionaries in the lists contain the name and type of the indices.
3467    """
3468    if pipe.__dict__.get('_skip_check_indices', False):
3469        return {}
3470
3471    from meerschaum.utils.sql import get_table_cols_indices
3472    return get_table_cols_indices(
3473        pipe.target,
3474        self,
3475        flavor=self.flavor,
3476        schema=self.get_pipe_schema(pipe),
3477        debug=debug,
3478    )

Return a dictionary mapping columns to the indices created on those columns.

Parameters
  • pipe (mrsm.Pipe): The pipe to be queried against.
Returns
  • A dictionary mapping columns names to lists of dictionaries.
  • The dictionaries in the lists contain the name and type of the indices.
@staticmethod
def get_temporary_target( target: str, transact_id: Optional[str] = None, label: Optional[str] = None, separator: Optional[str] = None) -> str:
4235@staticmethod
4236def get_temporary_target(
4237    target: str,
4238    transact_id: Optional[str] = None,
4239    label: Optional[str] = None,
4240    separator: Optional[str] = None,
4241) -> str:
4242    """
4243    Return a unique(ish) temporary target for a pipe.
4244    """
4245    from meerschaum.utils.misc import generate_password
4246    temp_target_cf = (
4247        mrsm.get_config('system', 'connectors', 'sql', 'instance', 'temporary_target') or {}
4248    )
4249    transaction_id_len = temp_target_cf.get('transaction_id_length', 3)
4250    transact_id = transact_id or generate_password(transaction_id_len)
4251    temp_prefix = temp_target_cf.get('prefix', '_')
4252    separator = separator or temp_target_cf.get('separator', '_')
4253    return (
4254        temp_prefix
4255        + target
4256        + separator
4257        + transact_id
4258        + ((separator + label) if label else '')
4259    )

Return a unique(ish) temporary target for a pipe.

def create_pipe_indices( self, pipe: meerschaum.Pipe, columns: Optional[List[str]] = None, debug: bool = False) -> Tuple[bool, str]:
366def create_pipe_indices(
367    self,
368    pipe: mrsm.Pipe,
369    columns: Optional[List[str]] = None,
370    debug: bool = False,
371) -> SuccessTuple:
372    """
373    Create a pipe's indices.
374    """
375    success = self.create_indices(pipe, columns=columns, debug=debug)
376    msg = (
377        "Success"
378        if success
379        else f"Failed to create indices for {pipe}."
380    )
381    return success, msg

Create a pipe's indices.

def drop_pipe_indices( self, pipe: meerschaum.Pipe, columns: Optional[List[str]] = None, debug: bool = False) -> Tuple[bool, str]:
422def drop_pipe_indices(
423    self,
424    pipe: mrsm.Pipe,
425    columns: Optional[List[str]] = None,
426    debug: bool = False,
427) -> SuccessTuple:
428    """
429    Drop a pipe's indices.
430    """
431    success = self.drop_indices(pipe, columns=columns, debug=debug)
432    msg = (
433        "Success"
434        if success
435        else f"Failed to drop indices for {pipe}."
436    )
437    return success, msg

Drop a pipe's indices.

def get_pipe_index_names(self, pipe: meerschaum.Pipe) -> Dict[str, str]:
474def get_pipe_index_names(self, pipe: mrsm.Pipe) -> Dict[str, str]:
475    """
476    Return a dictionary mapping index keys to their names on the database.
477
478    Returns
479    -------
480    A dictionary of index keys to column names.
481    """
482    from meerschaum.utils.sql import DEFAULT_SCHEMA_FLAVORS, truncate_item_name
483    _parameters = pipe.parameters
484    _index_template = _parameters.get('index_template', "IX_{schema_str}{target}_{column_names}")
485    _schema = self.get_pipe_schema(pipe)
486    if _schema is None:
487        _schema = (
488            DEFAULT_SCHEMA_FLAVORS.get(self.flavor, None)
489            if self.flavor != 'mssql'
490            else None
491        )
492    schema_str = '' if _schema is None else f'{_schema}_'
493    schema_str = ''
494    _indices = pipe.indices
495    _target = pipe.target
496    _column_names = {
497        ix: (
498            '_'.join(cols)
499            if isinstance(cols, (list, tuple))
500            else str(cols)
501        )
502        for ix, cols in _indices.items()
503        if cols
504    }
505    _index_names = {
506        ix: _index_template.format(
507            target=_target,
508            column_names=column_names,
509            connector_keys=pipe.connector_keys,
510            metric_key=pipe.metric_key,
511            location_key=pipe.location_key,
512            schema_str=schema_str,
513        )
514        for ix, column_names in _column_names.items()
515    }
516    ### NOTE: Skip any duplicate indices.
517    seen_index_names = {}
518    for ix, index_name in _index_names.items():
519        if index_name in seen_index_names:
520            continue
521        seen_index_names[index_name] = ix
522    return {
523        ix: truncate_item_name(index_name, flavor=self.flavor)
524        for index_name, ix in seen_index_names.items()
525    }

Return a dictionary mapping index keys to their names on the database.

Returns
  • A dictionary of index keys to column names.
def get_plugins_pipe(self) -> meerschaum.Pipe:
18def get_plugins_pipe(self) -> mrsm.Pipe:
19    """
20    Return the internal metadata plugins pipe.
21    """
22    users_pipe = self.get_users_pipe()
23    user_id_dtype = users_pipe.dtypes.get('user_id', 'int')
24    return mrsm.Pipe(
25        'mrsm', 'plugins',
26        instance=self,
27        temporary=True,
28        static=True,
29        null_indices=False,
30        columns={
31            'primary': 'plugin_id',
32            'user_id': 'user_id',    
33        },
34        dtypes={
35            'plugin_name': 'string',
36            'user_id': user_id_dtype,
37            'attributes': 'json',
38            'version': 'string',
39        },
40        indices={
41            'unique': 'plugin_name',
42        },
43    )

Return the internal metadata plugins pipe.

def register_plugin( self, plugin: meerschaum.Plugin, force: bool = False, debug: bool = False, **kw: Any) -> Tuple[bool, str]:
 46def register_plugin(
 47    self,
 48    plugin: 'mrsm.core.Plugin',
 49    force: bool = False,
 50    debug: bool = False,
 51    **kw: Any
 52) -> SuccessTuple:
 53    """Register a new plugin to the plugins table."""
 54    from meerschaum.utils.packages import attempt_import
 55    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
 56    from meerschaum.utils.sql import json_flavors
 57    from meerschaum.connectors.sql.tables import get_tables
 58    plugins_tbl = get_tables(mrsm_instance=self, debug=debug)['plugins']
 59
 60    old_id = self.get_plugin_id(plugin, debug=debug)
 61
 62    ### Check for version conflict. May be overridden with `--force`.
 63    if old_id is not None and not force:
 64        old_version = self.get_plugin_version(plugin, debug=debug)
 65        new_version = plugin.version
 66        if old_version is None:
 67            old_version = ''
 68        if new_version is None:
 69            new_version = ''
 70
 71        ### verify that the new version is greater than the old
 72        packaging_version = attempt_import('packaging.version')
 73        if (
 74            old_version and new_version
 75            and packaging_version.parse(old_version) >= packaging_version.parse(new_version)
 76        ):
 77            return False, (
 78                f"Version '{new_version}' of plugin '{plugin}' " +
 79                f"must be greater than existing version '{old_version}'."
 80            )
 81
 82    bind_variables = {
 83        'plugin_name': plugin.name,
 84        'version': plugin.version,
 85        'attributes': (
 86            json.dumps(plugin.attributes) if self.flavor not in json_flavors else plugin.attributes
 87        ),
 88        'user_id': plugin.user_id,
 89    }
 90
 91    if old_id is None:
 92        query = sqlalchemy.insert(plugins_tbl).values(**bind_variables)
 93    else:
 94        query = (
 95            sqlalchemy.update(plugins_tbl)
 96            .values(**bind_variables)
 97            .where(plugins_tbl.c.plugin_id == old_id)
 98        )
 99
100    result = self.exec(query, debug=debug)
101    if result is None:
102        return False, f"Failed to register plugin '{plugin}'."
103    return True, f"Successfully registered plugin '{plugin}'."

Register a new plugin to the plugins table.

def delete_plugin( self, plugin: meerschaum.Plugin, debug: bool = False, **kw: Any) -> Tuple[bool, str]:
272def delete_plugin(
273    self,
274    plugin: 'mrsm.core.Plugin',
275    debug: bool = False,
276    **kw: Any
277) -> SuccessTuple:
278    """Delete a plugin from the plugins table."""
279    from meerschaum.utils.packages import attempt_import
280    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
281    from meerschaum.connectors.sql.tables import get_tables
282    plugins_tbl = get_tables(mrsm_instance=self, debug=debug)['plugins']
283
284    plugin_id = self.get_plugin_id(plugin, debug=debug)
285    if plugin_id is None:
286        return True, f"Plugin '{plugin}' was not registered."
287
288    query = sqlalchemy.delete(plugins_tbl).where(plugins_tbl.c.plugin_id == plugin_id)
289    result = self.exec(query, debug=debug)
290    if result is None:
291        return False, f"Failed to delete plugin '{plugin}'."
292    return True, f"Successfully deleted plugin '{plugin}'."

Delete a plugin from the plugins table.

def get_plugin_id( self, plugin: meerschaum.Plugin, debug: bool = False) -> Optional[int]:
105def get_plugin_id(
106    self,
107    plugin: 'mrsm.core.Plugin',
108    debug: bool = False
109) -> Optional[int]:
110    """
111    Return a plugin's ID.
112    """
113    ### ensure plugins table exists
114    from meerschaum.connectors.sql.tables import get_tables
115    plugins_tbl = get_tables(mrsm_instance=self, debug=debug)['plugins']
116    from meerschaum.utils.packages import attempt_import
117    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
118
119    query = (
120        sqlalchemy
121        .select(plugins_tbl.c.plugin_id)
122        .where(plugins_tbl.c.plugin_name == plugin.name)
123    )
124    
125    try:
126        return int(self.value(query, debug=debug))
127    except Exception:
128        return None

Return a plugin's ID.

def get_plugin_version( self, plugin: meerschaum.Plugin, debug: bool = False) -> Optional[str]:
131def get_plugin_version(
132    self,
133    plugin: 'mrsm.core.Plugin',
134    debug: bool = False
135) -> Optional[str]:
136    """
137    Return a plugin's version.
138    """
139    ### ensure plugins table exists
140    from meerschaum.connectors.sql.tables import get_tables
141    plugins_tbl = get_tables(mrsm_instance=self, debug=debug)['plugins']
142    from meerschaum.utils.packages import attempt_import
143    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
144    query = sqlalchemy.select(plugins_tbl.c.version).where(plugins_tbl.c.plugin_name == plugin.name)
145    return self.value(query, debug=debug)

Return a plugin's version.

def get_plugins( self, user_id: Optional[int] = None, search_term: Optional[str] = None, debug: bool = False, **kw: Any) -> List[str]:
225def get_plugins(
226    self,
227    user_id: Optional[int] = None,
228    search_term: Optional[str] = None,
229    debug: bool = False,
230    **kw: Any
231) -> List[str]:
232    """
233    Return a list of all registered plugins.
234
235    Parameters
236    ----------
237    user_id: Optional[int], default None
238        If specified, filter plugins by a specific `user_id`.
239
240    search_term: Optional[str], default None
241        If specified, add a `WHERE plugin_name LIKE '{search_term}%'` clause to filter the plugins.
242
243
244    Returns
245    -------
246    A list of plugin names.
247    """
248    ### ensure plugins table exists
249    from meerschaum.connectors.sql.tables import get_tables
250    plugins_tbl = get_tables(mrsm_instance=self, debug=debug)['plugins']
251    from meerschaum.utils.packages import attempt_import
252    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
253
254    query = sqlalchemy.select(plugins_tbl.c.plugin_name)
255    if user_id is not None:
256        query = query.where(plugins_tbl.c.user_id == user_id)
257    if search_term is not None:
258        query = query.where(plugins_tbl.c.plugin_name.like(search_term + '%'))
259
260    rows = (
261        self.execute(query).fetchall()
262        if self.flavor != 'duckdb'
263        else [
264            (row['plugin_name'],)
265            for row in self.read(query).to_dict(orient='records')
266        ]
267    )
268    
269    return [row[0] for row in rows]

Return a list of all registered plugins.

Parameters
  • user_id (Optional[int], default None): If specified, filter plugins by a specific user_id.
  • search_term (Optional[str], default None): If specified, add a WHERE plugin_name LIKE '{search_term}%' clause to filter the plugins.
Returns
  • A list of plugin names.
def get_plugin_user_id( self, plugin: meerschaum.Plugin, debug: bool = False) -> Optional[int]:
147def get_plugin_user_id(
148    self,
149    plugin: 'mrsm.core.Plugin',
150    debug: bool = False
151) -> Optional[int]:
152    """
153    Return a plugin's user ID.
154    """
155    ### ensure plugins table exists
156    from meerschaum.connectors.sql.tables import get_tables
157    plugins_tbl = get_tables(mrsm_instance=self, debug=debug)['plugins']
158    from meerschaum.utils.packages import attempt_import
159    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
160
161    query = (
162        sqlalchemy
163        .select(plugins_tbl.c.user_id)
164        .where(plugins_tbl.c.plugin_name == plugin.name)
165    )
166
167    try:
168        return int(self.value(query, debug=debug))
169    except Exception:
170        return None

Return a plugin's user ID.

def get_plugin_username( self, plugin: meerschaum.Plugin, debug: bool = False) -> Optional[str]:
172def get_plugin_username(
173    self,
174    plugin: 'mrsm.core.Plugin',
175    debug: bool = False
176) -> Optional[str]:
177    """
178    Return the username of a plugin's owner.
179    """
180    ### ensure plugins table exists
181    from meerschaum.connectors.sql.tables import get_tables
182    plugins_tbl = get_tables(mrsm_instance=self, debug=debug)['plugins']
183    users = get_tables(mrsm_instance=self, debug=debug)['users']
184    from meerschaum.utils.packages import attempt_import
185    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
186
187    query = (
188        sqlalchemy.select(users.c.username)
189        .where(
190            users.c.user_id == plugins_tbl.c.user_id
191            and plugins_tbl.c.plugin_name == plugin.name
192        )
193    )
194
195    return self.value(query, debug=debug)

Return the username of a plugin's owner.

def get_plugin_attributes( self, plugin: meerschaum.Plugin, debug: bool = False) -> Dict[str, Any]:
198def get_plugin_attributes(
199    self,
200    plugin: 'mrsm.core.Plugin',
201    debug: bool = False
202) -> Dict[str, Any]:
203    """
204    Return the attributes of a plugin.
205    """
206    ### ensure plugins table exists
207    from meerschaum.connectors.sql.tables import get_tables
208    plugins_tbl = get_tables(mrsm_instance=self, debug=debug)['plugins']
209    from meerschaum.utils.packages import attempt_import
210    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
211
212    query = (
213        sqlalchemy
214        .select(plugins_tbl.c.attributes)
215        .where(plugins_tbl.c.plugin_name == plugin.name)
216    )
217
218    _attr = self.value(query, debug=debug)
219    if isinstance(_attr, str):
220        _attr = json.loads(_attr)
221    elif _attr is None:
222        _attr = {}
223    return _attr

Return the attributes of a plugin.

def get_users_pipe(self) -> meerschaum.Pipe:
16def get_users_pipe(self) -> mrsm.Pipe:
17    """
18    Return the internal metadata pipe for users management.
19    """
20    if '_users_pipe' in self.__dict__:
21        return self._users_pipe
22
23    cache_connector = self.__dict__.get('_cache_connector', None)
24    self._users_pipe = mrsm.Pipe(
25        'mrsm', 'users',
26        temporary=True,
27        cache=True,
28        cache_connector_keys=cache_connector,
29        static=True,
30        null_indices=False,
31        enforce=False,
32        autoincrement=True,
33        columns={
34            'primary': 'user_id',
35        },
36        dtypes={
37            'user_id': 'int',
38            'username': 'string',
39            'attributes': 'json',
40            'user_type': 'string',
41        },
42        indices={
43            'unique': 'username',
44        },
45    )
46    return self._users_pipe

Return the internal metadata pipe for users management.

def register_user( self, user: meerschaum.core.User._User.User, debug: bool = False, **kw: Any) -> Tuple[bool, str]:
49def register_user(
50    self,
51    user: mrsm.core.User,
52    debug: bool = False,
53    **kw: Any
54) -> SuccessTuple:
55    """Register a new user."""
56    from meerschaum.utils.packages import attempt_import
57    from meerschaum.utils.sql import json_flavors
58    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
59
60    valid_tuple = valid_username(user.username)
61    if not valid_tuple[0]:
62        return valid_tuple
63
64    old_id = self.get_user_id(user, debug=debug)
65
66    if old_id is not None:
67        return False, f"User '{user}' already exists."
68
69    ### ensure users table exists
70    from meerschaum.connectors.sql.tables import get_tables
71    tables = get_tables(mrsm_instance=self, debug=debug)
72
73    import json
74    bind_variables = {
75        'username': user.username,
76        'email': user.email,
77        'password_hash': user.password_hash,
78        'user_type': user.type,
79        'attributes': (
80            json.dumps(user.attributes)
81            if self.flavor not in json_flavors
82            else user.attributes
83        ),
84    }
85    if old_id is not None:
86        return False, f"User '{user.username}' already exists."
87    if old_id is None:
88        query = (
89            sqlalchemy.insert(tables['users']).
90            values(**bind_variables)
91        )
92
93    result = self.exec(query, debug=debug)
94    if result is None:
95        return False, f"Failed to register user '{user}'."
96    return True, f"Successfully registered user '{user}'."

Register a new user.

def get_user_id( self, user: meerschaum.core.User._User.User, debug: bool = False) -> Optional[int]:
188def get_user_id(
189    self,
190    user: 'mrsm.core.User',
191    debug: bool = False
192) -> Optional[int]:
193    """If a user is registered, return the `user_id`."""
194    ### ensure users table exists
195    from meerschaum.utils.packages import attempt_import
196    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
197    from meerschaum.connectors.sql.tables import get_tables
198    users_tbl = get_tables(mrsm_instance=self, debug=debug)['users']
199
200    query = (
201        sqlalchemy.select(users_tbl.c.user_id)
202        .where(users_tbl.c.username == user.username)
203    )
204
205    result = self.value(query, debug=debug)
206    if result is not None:
207        return int(result)
208    return None

If a user is registered, return the user_id.

def get_users(self, debug: bool = False, **kw: Any) -> List[str]:
282def get_users(
283    self,
284    debug: bool = False,
285    **kw: Any
286) -> List[str]:
287    """
288    Get the registered usernames.
289    """
290    ### ensure users table exists
291    from meerschaum.connectors.sql.tables import get_tables
292    users_tbl = get_tables(mrsm_instance=self, debug=debug)['users']
293    from meerschaum.utils.packages import attempt_import
294    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
295
296    query = sqlalchemy.select(users_tbl.c.username)
297
298    return list(self.read(query, debug=debug)['username'])

Get the registered usernames.

def edit_user( self, user: meerschaum.core.User._User.User, debug: bool = False, **kw: Any) -> Tuple[bool, str]:
133def edit_user(
134    self,
135    user: 'mrsm.core.User',
136    debug: bool = False,
137    **kw: Any
138) -> SuccessTuple:
139    """Update an existing user's metadata."""
140    from meerschaum.utils.packages import attempt_import
141    from meerschaum.utils.sql import json_flavors
142    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
143    from meerschaum.connectors.sql.tables import get_tables
144    users_tbl = get_tables(mrsm_instance=self, debug=debug)['users']
145
146    user_id = user.user_id if user.user_id is not None else self.get_user_id(user, debug=debug)
147    if user_id is None:
148        return False, (
149            f"User '{user.username}' does not exist. "
150            f"Register user '{user.username}' before editing."
151        )
152    user.user_id = user_id
153
154    import json
155    valid_tuple = valid_username(user.username)
156    if not valid_tuple[0]:
157        return valid_tuple
158
159    bind_variables = {
160        'user_id' : user_id,
161        'username' : user.username,
162    }
163    if user.password != '':
164        bind_variables['password_hash'] = user.password_hash
165    if user.email != '':
166        bind_variables['email'] = user.email
167    if user.attributes is not None and user.attributes != {}:
168        bind_variables['attributes'] = (
169            json.dumps(user.attributes) if self.flavor not in json_flavors
170            else user.attributes
171        )
172    if user.type != '':
173        bind_variables['user_type'] = user.type
174
175    query = (
176        sqlalchemy
177        .update(users_tbl)
178        .values(**bind_variables)
179        .where(users_tbl.c.user_id == user_id)
180    )
181
182    result = self.exec(query, debug=debug)
183    if result is None:
184        return False, f"Failed to edit user '{user}'."
185    return True, f"Successfully edited user '{user}'."

Update an existing user's metadata.

def delete_user( self, user: meerschaum.core.User._User.User, debug: bool = False) -> Tuple[bool, str]:
250def delete_user(
251    self,
252    user: 'mrsm.core.User',
253    debug: bool = False
254) -> SuccessTuple:
255    """Delete a user's record from the users table."""
256    ### ensure users table exists
257    from meerschaum.connectors.sql.tables import get_tables
258    users_tbl = get_tables(mrsm_instance=self, debug=debug)['users']
259    plugins = get_tables(mrsm_instance=self, debug=debug)['plugins']
260    from meerschaum.utils.packages import attempt_import
261    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
262
263    user_id = user.user_id if user.user_id is not None else self.get_user_id(user, debug=debug)
264
265    if user_id is None:
266        return False, f"User '{user.username}' is not registered and cannot be deleted."
267
268    query = sqlalchemy.delete(users_tbl).where(users_tbl.c.user_id == user_id)
269
270    result = self.exec(query, debug=debug)
271    if result is None:
272        return False, f"Failed to delete user '{user}'."
273
274    query = sqlalchemy.delete(plugins).where(plugins.c.user_id == user_id)
275    result = self.exec(query, debug=debug)
276    if result is None:
277        return False, f"Failed to delete plugins of user '{user}'."
278
279    return True, f"Successfully deleted user '{user}'"

Delete a user's record from the users table.

def get_user_password_hash( self, user: meerschaum.core.User._User.User, debug: bool = False, **kw: Any) -> Optional[str]:
301def get_user_password_hash(
302    self,
303    user: 'mrsm.core.User',
304    debug: bool = False,
305    **kw: Any
306) -> Optional[str]:
307    """
308    Return the password has for a user.
309    **NOTE**: This may be dangerous and is only allowed if the security settings explicity allow it.
310    """
311    from meerschaum.utils.debug import dprint
312    from meerschaum.connectors.sql.tables import get_tables
313    users_tbl = get_tables(mrsm_instance=self, debug=debug)['users']
314    from meerschaum.utils.packages import attempt_import
315    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
316
317    if user.user_id is not None:
318        user_id = user.user_id
319        if debug:
320            dprint(f"Already given user_id: {user_id}")
321    else:
322        if debug:
323            dprint("Fetching user_id...")
324        user_id = self.get_user_id(user, debug=debug)
325
326    if user_id is None:
327        return None
328
329    query = sqlalchemy.select(users_tbl.c.password_hash).where(users_tbl.c.user_id == user_id)
330
331    return self.value(query, debug=debug)

Return the password has for a user. NOTE: This may be dangerous and is only allowed if the security settings explicity allow it.

def get_user_type( self, user: meerschaum.core.User._User.User, debug: bool = False, **kw: Any) -> Optional[str]:
334def get_user_type(
335    self,
336    user: 'mrsm.core.User',
337    debug: bool = False,
338    **kw: Any
339) -> Optional[str]:
340    """
341    Return the user's type.
342    """
343    from meerschaum.connectors.sql.tables import get_tables
344    users_tbl = get_tables(mrsm_instance=self, debug=debug)['users']
345    from meerschaum.utils.packages import attempt_import
346    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
347
348    user_id = user.user_id if user.user_id is not None else self.get_user_id(user, debug=debug)
349
350    if user_id is None:
351        return None
352
353    query = sqlalchemy.select(users_tbl.c.user_type).where(users_tbl.c.user_id == user_id)
354
355    return self.value(query, debug=debug)

Return the user's type.

def get_user_attributes( self, user: meerschaum.core.User._User.User, debug: bool = False) -> Optional[Dict[str, Any]]:
210def get_user_attributes(
211    self,
212    user: 'mrsm.core.User',
213    debug: bool = False
214) -> Union[Dict[str, Any], None]:
215    """
216    Return the user's attributes.
217    """
218    ### ensure users table exists
219    from meerschaum.utils.warnings import warn
220    from meerschaum.utils.packages import attempt_import
221    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
222    from meerschaum.connectors.sql.tables import get_tables
223    users_tbl = get_tables(mrsm_instance=self, debug=debug)['users']
224
225    user_id = user.user_id if user.user_id is not None else self.get_user_id(user, debug=debug)
226
227    query = (
228        sqlalchemy.select(users_tbl.c.attributes)
229        .where(users_tbl.c.user_id == user_id)
230    )
231
232    result = self.value(query, debug=debug)
233    if result is not None and not isinstance(result, dict):
234        try:
235            result = dict(result)
236            _parsed = True
237        except Exception:
238            _parsed = False
239        if not _parsed:
240            try:
241                import json
242                result = json.loads(result)
243                _parsed = True
244            except Exception:
245                _parsed = False
246        if not _parsed:
247            warn(f"Received unexpected type for attributes: {result}")
248    return result

Return the user's attributes.

@classmethod
def from_uri( cls, uri: str, label: Optional[str] = None, as_dict: bool = False) -> Union[SQLConnector, Dict[str, Union[str, int]]]:
15@classmethod
16def from_uri(
17    cls,
18    uri: str,
19    label: Optional[str] = None,
20    as_dict: bool = False,
21) -> Union[
22    'meerschaum.connectors.SQLConnector',
23    Dict[str, Union[str, int]],
24]:
25    """
26    Create a new SQLConnector from a URI string.
27
28    Parameters
29    ----------
30    uri: str
31        The URI connection string.
32
33    label: Optional[str], default None
34        If provided, use this as the connector label.
35        Otherwise use the determined database name.
36
37    as_dict: bool, default False
38        If `True`, return a dictionary of the keyword arguments
39        necessary to create a new `SQLConnector`, otherwise create a new object.
40
41    Returns
42    -------
43    A new SQLConnector object or a dictionary of attributes (if `as_dict` is `True`).
44    """
45
46    params = cls.parse_uri(uri)
47    params['uri'] = uri
48    flavor = params.get('flavor', None)
49    if not flavor or flavor not in cls.flavor_configs:
50        error(f"Invalid flavor '{flavor}' detected from the provided URI.")
51
52    if 'database' not in params:
53        error("Unable to determine the database from the provided URI.")
54
55    if flavor in ('sqlite', 'duckdb', 'geopackage'):
56        if params['database'] == ':memory:':
57            params['label'] = label or f'memory_{flavor}'
58        else:
59            params['label'] = label or params['database'].split(os.path.sep)[-1].lower()
60    else:
61        params['label'] = label or (
62            (
63                (params['username'] + '@' if 'username' in params else '')
64                + params.get('host', '')
65                + ('/' if 'host' in params else '')
66                + params.get('database', '')
67            ).lower()
68        )
69
70    return cls(**params) if not as_dict else params

Create a new SQLConnector from a URI string.

Parameters
  • uri (str): The URI connection string.
  • label (Optional[str], default None): If provided, use this as the connector label. Otherwise use the determined database name.
  • as_dict (bool, default False): If True, return a dictionary of the keyword arguments necessary to create a new SQLConnector, otherwise create a new object.
Returns
  • A new SQLConnector object or a dictionary of attributes (if as_dict is True).
@staticmethod
def parse_uri(uri: str) -> Dict[str, Any]:
 73@staticmethod
 74def parse_uri(uri: str) -> Dict[str, Any]:
 75    """
 76    Parse a URI string into a dictionary of parameters.
 77
 78    Parameters
 79    ----------
 80    uri: str
 81        The database connection URI.
 82
 83    Returns
 84    -------
 85    A dictionary of attributes.
 86
 87    Examples
 88    --------
 89    >>> parse_uri('sqlite:////home/foo/bar.db')
 90    {'database': '/home/foo/bar.db', 'flavor': 'sqlite'}
 91    >>> parse_uri(
 92    ...     'mssql+pyodbc://sa:supersecureSECRETPASSWORD123!@localhost:1439'
 93    ...     + '/master?driver=ODBC+Driver+17+for+SQL+Server'
 94    ... )
 95    {'host': 'localhost', 'database': 'master', 'username': 'sa',
 96    'password': 'supersecureSECRETPASSWORD123!', 'port': 1439, 'flavor': 'mssql',
 97    'driver': 'ODBC Driver 17 for SQL Server'}
 98    >>> 
 99    """
100    from urllib.parse import parse_qs, urlparse
101    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
102    parser = sqlalchemy.engine.url.make_url
103    params = parser(uri).translate_connect_args()
104    params['flavor'] = uri.split(':')[0].split('+')[0]
105    if params['flavor'] == 'postgres':
106        params['flavor'] = 'postgresql'
107    if '?' in uri:
108        parsed_uri = urlparse(uri)
109        for key, value in parse_qs(parsed_uri.query).items():
110            params.update({key: value[0]})
111
112        if '--search_path' in params.get('options', ''):
113            params.update({'schema': params['options'].replace('--search_path=', '', 1)})
114    return params

Parse a URI string into a dictionary of parameters.

Parameters
  • uri (str): The database connection URI.
Returns
  • A dictionary of attributes.
Examples
>>> parse_uri('sqlite:////home/foo/bar.db')
{'database': '/home/foo/bar.db', 'flavor': 'sqlite'}
>>> parse_uri(
...     'mssql+pyodbc://sa:supersecureSECRETPASSWORD123!@localhost:1439'
...     + '/master?driver=ODBC+Driver+17+for+SQL+Server'
... )
{'host': 'localhost', 'database': 'master', 'username': 'sa',
'password': 'supersecureSECRETPASSWORD123!', 'port': 1439, 'flavor': 'mssql',
'driver': 'ODBC Driver 17 for SQL Server'}
>>>