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

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:
379def create_indices(
380    self,
381    pipe: mrsm.Pipe,
382    columns: Optional[List[str]] = None,
383    indices: Optional[List[str]] = None,
384    debug: bool = False
385) -> bool:
386    """
387    Create a pipe's indices.
388    """
389    if pipe.__dict__.get('_skip_check_indices', False):
390        return True
391
392    if debug:
393        dprint(f"Creating indices for {pipe}...")
394
395    if not pipe.indices:
396        warn(f"{pipe} has no index columns; skipping index creation.", stack=False)
397        return True
398
399    cols_to_include = set((columns or []) + (indices or [])) or None
400
401    pipe._clear_cache_key('_columns_indices', debug=debug)
402    ix_queries = {
403        col: queries
404        for col, queries in self.get_create_index_queries(pipe, debug=debug).items()
405        if cols_to_include is None or col in cols_to_include
406    }
407    success = True
408    for col, queries in ix_queries.items():
409        ix_success = all(self.exec_queries(queries, debug=debug, silent=False))
410        success = success and ix_success
411        if not ix_success:
412            warn(f"Failed to create index on column: {col}")
413
414    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:
435def drop_indices(
436    self,
437    pipe: mrsm.Pipe,
438    columns: Optional[List[str]] = None,
439    indices: Optional[List[str]] = None,
440    debug: bool = False
441) -> bool:
442    """
443    Drop a pipe's indices.
444    """
445    if debug:
446        dprint(f"Dropping indices for {pipe}...")
447
448    if not pipe.indices:
449        warn(f"No indices to drop for {pipe}.", stack=False)
450        return False
451
452    cols_to_include = set((columns or []) + (indices or [])) or None
453
454    ix_queries = {
455        col: queries
456        for col, queries in self.get_drop_index_queries(pipe, debug=debug).items()
457        if cols_to_include is None or col in cols_to_include
458    }
459    success = True
460    for col, queries in ix_queries.items():
461        ix_success = all(self.exec_queries(queries, debug=debug, silent=(not debug)))
462        if not ix_success:
463            success = False
464            if debug:
465                dprint(f"Failed to drop index on column: {col}")
466    return success

Drop a pipe's indices.

def get_create_index_queries( self, pipe: meerschaum.Pipe, debug: bool = False) -> Dict[str, List[str]]:
532def get_create_index_queries(
533    self,
534    pipe: mrsm.Pipe,
535    debug: bool = False,
536) -> Dict[str, List[str]]:
537    """
538    Return a dictionary mapping columns to a `CREATE INDEX` or equivalent query.
539
540    Parameters
541    ----------
542    pipe: mrsm.Pipe
543        The pipe to which the queries will correspond.
544
545    Returns
546    -------
547    A dictionary of index names mapping to lists of queries.
548    """
549    ### NOTE: Due to recent breaking changes in DuckDB, indices don't behave properly.
550    if self.flavor == 'duckdb':
551        return {}
552    from meerschaum.utils.sql import (
553        sql_item_name,
554        get_distinct_col_count,
555        UPDATE_QUERIES,
556        get_null_replacement,
557        get_create_table_queries,
558        get_rename_table_queries,
559        COALESCE_UNIQUE_INDEX_FLAVORS,
560    )
561    from meerschaum.utils.dtypes import are_dtypes_equal
562    from meerschaum.utils.dtypes.sql import (
563        get_db_type_from_pd_type,
564        get_pd_type_from_db_type,
565        AUTO_INCREMENT_COLUMN_FLAVORS,
566    )
567    from meerschaum.config import get_config
568    index_queries = {}
569
570    upsert = pipe.parameters.get('upsert', False) and (self.flavor + '-upsert') in UPDATE_QUERIES
571    static = pipe.parameters.get('static', False)
572    null_indices = pipe.parameters.get('null_indices', True)
573    index_names = pipe.get_indices()
574    unique_index_name_unquoted = index_names.get('unique', None) or f'IX_{pipe.target}_unique'
575    if upsert:
576        _ = index_names.pop('unique', None)
577    indices = pipe.indices
578    existing_cols_types = pipe.get_columns_types(debug=debug)
579    existing_cols_pd_types = {
580        col: get_pd_type_from_db_type(typ)
581        for col, typ in existing_cols_types.items()
582    }
583    existing_cols_indices = self.get_pipe_columns_indices(pipe, debug=debug)
584    existing_ix_names = set()
585    existing_primary_keys = []
586    existing_clustered_primary_keys = []
587    for col, col_indices in existing_cols_indices.items():
588        for col_ix_doc in col_indices:
589            existing_ix_names.add(col_ix_doc.get('name', '').lower())
590            if col_ix_doc.get('type', None) == 'PRIMARY KEY':
591                existing_primary_keys.append(col.lower())
592                if col_ix_doc.get('clustered', True):
593                    existing_clustered_primary_keys.append(col.lower())
594
595    _datetime = pipe.get_columns('datetime', error=False)
596    _datetime_name = (
597        sql_item_name(_datetime, self.flavor, None)
598        if _datetime is not None else None
599    )
600    _datetime_index_name = (
601        sql_item_name(index_names['datetime'], flavor=self.flavor, schema=None)
602        if index_names.get('datetime', None)
603        else None
604    )
605    _id = pipe.get_columns('id', error=False)
606    _id_name = (
607        sql_item_name(_id, self.flavor, None)
608        if _id is not None
609        else None
610    )
611    primary_key = pipe.columns.get('primary', None)
612    primary_key_name = (
613        sql_item_name(primary_key, flavor=self.flavor, schema=None)
614        if primary_key
615        else None
616    )
617    autoincrement = (
618        pipe.parameters.get('autoincrement', False)
619        or (
620            primary_key is not None
621            and primary_key not in existing_cols_pd_types
622        )
623    )
624    primary_key_db_type = (
625        get_db_type_from_pd_type(pipe.dtypes.get(primary_key, 'int') or 'int', self.flavor)
626        if primary_key
627        else None
628    )
629    primary_key_constraint_name = (
630        sql_item_name(f'PK_{pipe.target}', self.flavor, None)
631        if primary_key is not None
632        else None
633    )
634    primary_key_clustered = "CLUSTERED" if _datetime is None else "NONCLUSTERED"
635    datetime_clustered = (
636        "CLUSTERED"
637        if not existing_clustered_primary_keys and _datetime is not None
638        else "NONCLUSTERED"
639    )
640    include_columns_str = "\n    ,".join(
641        [
642            sql_item_name(col, flavor=self.flavor) for col in existing_cols_types
643            if col != _datetime
644        ]
645    ).rstrip(',')
646    include_clause = (
647        (
648            f"\nINCLUDE (\n    {include_columns_str}\n)"
649        )
650        if datetime_clustered == 'NONCLUSTERED'
651        else ''
652    )
653
654    _id_index_name = (
655        sql_item_name(index_names['id'], self.flavor, None)
656        if index_names.get('id', None)
657        else None
658    )
659    _pipe_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
660    _create_space_partition = get_config('system', 'experimental', 'space')
661
662    ### create datetime index
663    dt_query = None
664    if _datetime is not None:
665        if (
666            self.flavor in ('timescaledb', 'timescaledb-ha')
667            and pipe.parameters.get('hypertable', True)
668        ):
669            _id_count = (
670                get_distinct_col_count(_id, f"SELECT {_id_name} FROM {_pipe_name}", self)
671                if (_id is not None and _create_space_partition) else None
672            )
673
674            chunk_interval = pipe.get_chunk_interval(debug=debug)
675            chunk_interval_minutes = (
676                chunk_interval
677                if isinstance(chunk_interval, int)
678                else int(chunk_interval.total_seconds() / 60)
679            )
680            chunk_time_interval = (
681                f"INTERVAL '{chunk_interval_minutes} MINUTES'"
682                if isinstance(chunk_interval, timedelta)
683                else f'{chunk_interval_minutes}'
684            )
685
686            dt_query = (
687                f"SELECT public.create_hypertable('{_pipe_name}', " +
688                f"'{_datetime}', "
689                + (
690                    f"'{_id}', {_id_count}, " if (_id is not None and _create_space_partition)
691                    else ''
692                )
693                + f'chunk_time_interval => {chunk_time_interval}, '
694                + 'if_not_exists => true, '
695                + "migrate_data => true);"
696            )
697        elif _datetime_index_name and _datetime != primary_key:
698            if self.flavor == 'mssql':
699                dt_query = (
700                    f"CREATE {datetime_clustered} INDEX {_datetime_index_name} "
701                    f"\nON {_pipe_name} ({_datetime_name}){include_clause}"
702                )
703            else:
704                dt_query = (
705                    f"CREATE INDEX {_datetime_index_name} "
706                    + f"ON {_pipe_name} ({_datetime_name})"
707                )
708
709    if dt_query:
710        index_queries[_datetime] = [dt_query]
711
712    primary_queries = []
713    if (
714        primary_key is not None
715        and primary_key.lower() not in existing_primary_keys
716        and not static
717    ):
718        if autoincrement and primary_key not in existing_cols_pd_types:
719            autoincrement_str = AUTO_INCREMENT_COLUMN_FLAVORS.get(
720                self.flavor,
721                AUTO_INCREMENT_COLUMN_FLAVORS['default']
722            )
723            primary_queries.extend([
724                (
725                    f"ALTER TABLE {_pipe_name}\n"
726                    f"ADD {primary_key_name} {primary_key_db_type} {autoincrement_str}"
727                ),
728            ])
729        elif not autoincrement and primary_key in existing_cols_pd_types:
730            if self.flavor in ('sqlite', 'geopackage'):
731                new_table_name = sql_item_name(
732                    f'_new_{pipe.target}',
733                    self.flavor,
734                    self.get_pipe_schema(pipe)
735                )
736                select_cols_str = ', '.join(
737                    [
738                        sql_item_name(col, self.flavor, None)
739                        for col in existing_cols_types
740                    ]
741                )
742                primary_queries.extend(
743                    get_create_table_queries(
744                        existing_cols_pd_types,
745                        f'_new_{pipe.target}',
746                        self.flavor,
747                        schema=self.get_pipe_schema(pipe),
748                        primary_key=primary_key,
749                    ) + [
750                        (
751                            f"INSERT INTO {new_table_name} ({select_cols_str})\n"
752                            f"SELECT {select_cols_str}\nFROM {_pipe_name}"
753                        ),
754                        f"DROP TABLE {_pipe_name}",
755                    ] + get_rename_table_queries(
756                        f'_new_{pipe.target}',
757                        pipe.target,
758                        self.flavor,
759                        schema=self.get_pipe_schema(pipe),
760                    )
761                )
762            elif self.flavor == 'oracle':
763                primary_queries.extend([
764                    (
765                        f"ALTER TABLE {_pipe_name}\n"
766                        f"MODIFY {primary_key_name} NOT NULL"
767                    ),
768                    (
769                        f"ALTER TABLE {_pipe_name}\n"
770                        f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY ({primary_key_name})"
771                    )
772                ])
773            elif self.flavor in ('mysql', 'mariadb'):
774                primary_queries.extend([
775                    (
776                        f"ALTER TABLE {_pipe_name}\n"
777                        f"MODIFY {primary_key_name} {primary_key_db_type} NOT NULL"
778                    ),
779                    (
780                        f"ALTER TABLE {_pipe_name}\n"
781                        f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY ({primary_key_name})"
782                    )
783                ])
784            elif self.flavor in ('timescaledb', 'timescaledb-ha'):
785                primary_queries.extend([
786                    (
787                        f"ALTER TABLE {_pipe_name}\n"
788                        f"ALTER COLUMN {primary_key_name} SET NOT NULL"
789                    ),
790                    (
791                        f"ALTER TABLE {_pipe_name}\n"
792                        f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY (" + (
793                            f"{_datetime_name}, " if _datetime_name else ""
794                        ) + f"{primary_key_name})"
795                    ),
796                ])
797            elif self.flavor in ('citus', 'postgresql', 'duckdb', 'postgis'):
798                primary_queries.extend([
799                    (
800                        f"ALTER TABLE {_pipe_name}\n"
801                        f"ALTER COLUMN {primary_key_name} SET NOT NULL"
802                    ),
803                    (
804                        f"ALTER TABLE {_pipe_name}\n"
805                        f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY ({primary_key_name})"
806                    ),
807                ])
808            else:
809                primary_queries.extend([
810                    (
811                        f"ALTER TABLE {_pipe_name}\n"
812                        f"ALTER COLUMN {primary_key_name} {primary_key_db_type} NOT NULL"
813                    ),
814                    (
815                        f"ALTER TABLE {_pipe_name}\n"
816                        f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY {primary_key_clustered} ({primary_key_name})"
817                    ),
818                ])
819        index_queries[primary_key] = primary_queries
820
821    ### create id index
822    if _id_name is not None:
823        if self.flavor in ('timescaledb', 'timescaledb-ha'):
824            ### Already created indices via create_hypertable.
825            id_query = (
826                None if (_id is not None and _create_space_partition)
827                else (
828                    f"CREATE INDEX IF NOT EXISTS {_id_index_name} ON {_pipe_name} ({_id_name})"
829                    if _id is not None
830                    else None
831                )
832            )
833            pass
834        else: ### mssql, sqlite, etc.
835            id_query = (
836                None
837                if _is_non_indexable_col(_id, existing_cols_types, self.flavor)
838                else f"CREATE INDEX {_id_index_name} ON {_pipe_name} ({_id_name})"
839            )
840
841        if id_query is not None:
842            index_queries[_id] = id_query if isinstance(id_query, list) else [id_query]
843
844    ### Create indices for other labels in `pipe.columns`.
845    other_index_names = {
846        ix_key: ix_unquoted
847        for ix_key, ix_unquoted in index_names.items()
848        if (
849            ix_key not in ('datetime', 'id', 'primary')
850            and ix_unquoted.lower() not in existing_ix_names
851        )
852    }
853    for ix_key, ix_unquoted in other_index_names.items():
854        ix_name = sql_item_name(ix_unquoted, self.flavor, None)
855        cols = indices[ix_key]
856        if not isinstance(cols, (list, tuple)):
857            cols = [cols]
858        if ix_key == 'unique' and upsert:
859            continue
860        if self.flavor in ('mysql', 'mariadb', 'mssql'):
861            cols = [
862                col for col in cols
863                if col and not _is_non_indexable_col(
864                    col, existing_cols_types, self.flavor
865                )
866            ]
867        cols_names = [sql_item_name(col, self.flavor, None) for col in cols if col]
868        if not cols_names:
869            continue
870
871        cols_names_str = ", ".join(cols_names)
872        index_query_params_clause = f" ({cols_names_str})"
873        if self.flavor in ('postgis', 'timescaledb-ha'):
874            for col in cols:
875                col_typ = existing_cols_pd_types.get(cols[0], 'object')
876                if col_typ != 'object' and are_dtypes_equal(col_typ, 'geometry'):
877                    index_query_params_clause = f" USING GIST ({cols_names_str})"
878                    break
879
880        index_queries[ix_key] = [
881            f"CREATE INDEX {ix_name} ON {_pipe_name}{index_query_params_clause}"
882        ]
883
884    indices_cols_str = ', '.join(
885        list({
886            sql_item_name(ix, self.flavor)
887            for ix_key, ix in pipe.columns.items()
888            if ix and ix in existing_cols_types
889        })
890    )
891    coalesce_indices_cols_str = ', '.join(
892        [
893            (
894                (
895                    "COALESCE("
896                    + sql_item_name(ix, self.flavor)
897                    + ", "
898                    + get_null_replacement(existing_cols_types[ix], self.flavor)
899                    + ") "
900                )
901                if ix_key != 'datetime' and null_indices
902                else sql_item_name(ix, self.flavor)
903            )
904            for ix_key, ix in pipe.columns.items()
905            if ix and ix in existing_cols_types
906        ]
907    )
908    unique_index_name = sql_item_name(unique_index_name_unquoted, self.flavor)
909    constraint_name_unquoted = unique_index_name_unquoted.replace('IX_', 'UQ_')
910    constraint_name = sql_item_name(constraint_name_unquoted, self.flavor)
911    add_constraint_query = (
912        f"ALTER TABLE {_pipe_name} ADD CONSTRAINT {constraint_name} UNIQUE ({indices_cols_str})"
913    )
914    unique_index_cols_str = (
915        indices_cols_str
916        if self.flavor not in COALESCE_UNIQUE_INDEX_FLAVORS or not null_indices
917        else coalesce_indices_cols_str
918    )
919    create_unique_index_query = (
920        f"CREATE UNIQUE INDEX {unique_index_name} ON {_pipe_name} ({unique_index_cols_str})"
921    )
922    constraint_queries = [create_unique_index_query]
923    if self.flavor not in ('sqlite', 'geopackage'):
924        constraint_queries.append(add_constraint_query)
925    if upsert and indices_cols_str and unique_index_name_unquoted.lower() not in existing_ix_names:
926        index_queries[unique_index_name] = constraint_queries
927        ### Remove regular indices that cover the same single column as the unique index.
928        ### Some flavors (e.g. Oracle) reject two indices on the same column combination.
929        if unique_index_cols_str == _id_name:
930            index_queries.pop(_id, None)
931        if unique_index_cols_str == _datetime_name:
932            index_queries.pop(_datetime, None)
933    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]]:
 936def get_drop_index_queries(
 937    self,
 938    pipe: mrsm.Pipe,
 939    debug: bool = False,
 940) -> Dict[str, List[str]]:
 941    """
 942    Return a dictionary mapping columns to a `DROP INDEX` or equivalent query.
 943
 944    Parameters
 945    ----------
 946    pipe: mrsm.Pipe
 947        The pipe to which the queries will correspond.
 948
 949    Returns
 950    -------
 951    A dictionary of column names mapping to lists of queries.
 952    """
 953    ### NOTE: Due to breaking changes within DuckDB, indices must be skipped.
 954    if self.flavor == 'duckdb':
 955        return {}
 956    if not pipe.exists(debug=debug):
 957        return {}
 958
 959    from collections import defaultdict
 960    from meerschaum.utils.sql import (
 961        sql_item_name,
 962        table_exists,
 963        hypertable_queries,
 964        DROP_INDEX_IF_EXISTS_FLAVORS,
 965    )
 966    drop_queries = defaultdict(lambda: [])
 967    schema = self.get_pipe_schema(pipe)
 968    index_schema = schema if self.flavor != 'mssql' else None
 969    indices = {
 970        ix_key: ix
 971        for ix_key, ix in pipe.get_indices().items()
 972    }
 973    cols_indices = pipe.get_columns_indices(debug=debug)
 974    existing_indices = set()
 975    clustered_ix = None
 976    for col, ix_metas in cols_indices.items():
 977        for ix_meta in ix_metas:
 978            ix_name = ix_meta.get('name', None)
 979            if ix_meta.get('clustered', False):
 980                clustered_ix = ix_name
 981            existing_indices.add(ix_name.lower())
 982    pipe_name = sql_item_name(pipe.target, self.flavor, schema)
 983    pipe_name_no_schema = sql_item_name(pipe.target, self.flavor, None)
 984    upsert = pipe.upsert
 985
 986    if self.flavor not in hypertable_queries:
 987        is_hypertable = False
 988    else:
 989        is_hypertable_query = hypertable_queries[self.flavor].format(table_name=pipe_name)
 990        is_hypertable = self.value(is_hypertable_query, silent=True, debug=debug) is not None
 991
 992    if_exists_str = "IF EXISTS " if self.flavor in DROP_INDEX_IF_EXISTS_FLAVORS else ""
 993    if is_hypertable:
 994        nuke_queries = []
 995        temp_table = '_' + pipe.target + '_temp_migration'
 996        temp_table_name = sql_item_name(temp_table, self.flavor, self.get_pipe_schema(pipe))
 997
 998        if table_exists(temp_table, self, schema=self.get_pipe_schema(pipe), debug=debug):
 999            nuke_queries.append(f"DROP TABLE {if_exists_str} {temp_table_name}")
1000        nuke_queries += [
1001            f"SELECT * INTO {temp_table_name} FROM {pipe_name}",
1002            f"DROP TABLE {if_exists_str}{pipe_name}",
1003            f"ALTER TABLE {temp_table_name} RENAME TO {pipe_name_no_schema}",
1004        ]
1005        nuke_ix_keys = ('datetime', 'id')
1006        nuked = False
1007        for ix_key in nuke_ix_keys:
1008            if ix_key in indices and not nuked:
1009                drop_queries[ix_key].extend(nuke_queries)
1010                nuked = True
1011
1012    for ix_key, ix_unquoted in indices.items():
1013        if ix_key in drop_queries:
1014            continue
1015        if ix_unquoted.lower() not in existing_indices:
1016            continue
1017
1018        if (
1019            ix_key == 'unique'
1020            and upsert
1021            and self.flavor not in ('sqlite', 'geopackage')
1022            and not is_hypertable
1023        ):
1024            constraint_name_unquoted = ix_unquoted.replace('IX_', 'UQ_')
1025            constraint_name = sql_item_name(constraint_name_unquoted, self.flavor)
1026            constraint_or_index = (
1027                "CONSTRAINT"
1028                if self.flavor not in ('mysql', 'mariadb')
1029                else 'INDEX'
1030            )
1031            drop_queries[ix_key].append(
1032                f"ALTER TABLE {pipe_name}\n"
1033                f"DROP {constraint_or_index} {constraint_name}"
1034            )
1035
1036        query = (
1037            (
1038                f"ALTER TABLE {pipe_name}\n"
1039                if self.flavor in ('mysql', 'mariadb')
1040                else ''
1041            )
1042            + f"DROP INDEX {if_exists_str}"
1043            + sql_item_name(ix_unquoted, self.flavor, index_schema)
1044        )
1045        if self.flavor == 'mssql':
1046            query += f"\nON {pipe_name}"
1047            if ix_unquoted == clustered_ix:
1048                query += "\nWITH (ONLINE = ON, MAXDOP = 4)"
1049        drop_queries[ix_key].append(query)
1050
1051
1052    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]:
3453def get_add_columns_queries(
3454    self,
3455    pipe: mrsm.Pipe,
3456    df: Union[pd.DataFrame, Dict[str, str]],
3457    _is_db_types: bool = False,
3458    debug: bool = False,
3459) -> List[str]:
3460    """
3461    Add new null columns of the correct type to a table from a dataframe.
3462
3463    Parameters
3464    ----------
3465    pipe: mrsm.Pipe
3466        The pipe to be altered.
3467
3468    df: Union[pd.DataFrame, Dict[str, str]]
3469        The pandas DataFrame which contains new columns.
3470        If a dictionary is provided, assume it maps columns to Pandas data types.
3471
3472    _is_db_types: bool, default False
3473        If `True`, assume `df` is a dictionary mapping columns to SQL native dtypes.
3474
3475    Returns
3476    -------
3477    A list of the `ALTER TABLE` SQL query or queries to be executed on the provided connector.
3478    """
3479    if not pipe.exists(debug=debug):
3480        return []
3481
3482    if pipe.parameters.get('static', False):
3483        return []
3484
3485    from decimal import Decimal
3486    import copy
3487    from meerschaum.utils.sql import (
3488        sql_item_name,
3489        SINGLE_ALTER_TABLE_FLAVORS,
3490        get_table_cols_types,
3491    )
3492    from meerschaum.utils.dtypes.sql import (
3493        get_pd_type_from_db_type,
3494        get_db_type_from_pd_type,
3495    )
3496    from meerschaum.utils.misc import flatten_list
3497    is_dask = 'dask' in df.__module__ if not isinstance(df, dict) else False
3498    if is_dask:
3499        df = df.partitions[0].compute()
3500    df_cols_types = (
3501        {
3502            col: str(typ)
3503            for col, typ in df.dtypes.items()
3504        }
3505        if not isinstance(df, dict)
3506        else copy.deepcopy(df)
3507    )
3508    if not isinstance(df, dict) and len(df.index) > 0:
3509        for col, typ in list(df_cols_types.items()):
3510            if typ != 'object':
3511                continue
3512            val = df.iloc[0][col]
3513            if isinstance(val, (dict, list)):
3514                df_cols_types[col] = 'json'
3515            elif isinstance(val, Decimal):
3516                df_cols_types[col] = 'numeric'
3517            elif isinstance(val, str):
3518                df_cols_types[col] = 'str'
3519    db_cols_types = {
3520        col: get_pd_type_from_db_type(typ)
3521        for col, typ in get_table_cols_types(
3522            pipe.target,
3523            self,
3524            schema=self.get_pipe_schema(pipe),
3525            debug=debug,
3526        ).items()
3527    }
3528    new_cols = set(df_cols_types) - set(db_cols_types)
3529    if not new_cols:
3530        return []
3531
3532    new_cols_types = {
3533        col: get_db_type_from_pd_type(
3534            df_cols_types[col],
3535            self.flavor
3536        )
3537        for col in new_cols
3538        if col and df_cols_types.get(col, None)
3539    }
3540
3541    alter_table_query = "ALTER TABLE " + sql_item_name(
3542        pipe.target, self.flavor, self.get_pipe_schema(pipe)
3543    )
3544    queries = []
3545    for col, typ in new_cols_types.items():
3546        add_col_query = (
3547            "\nADD "
3548            + sql_item_name(col, self.flavor, None)
3549            + " " + typ + ","
3550        )
3551
3552        if self.flavor in SINGLE_ALTER_TABLE_FLAVORS:
3553            queries.append(alter_table_query + add_col_query[:-1])
3554        else:
3555            alter_table_query += add_col_query
3556
3557    ### For most flavors, only one query is required.
3558    ### This covers SQLite which requires one query per column.
3559    if not queries:
3560        queries.append(alter_table_query[:-1])
3561
3562    if self.flavor != 'duckdb':
3563        return queries
3564
3565    ### NOTE: For DuckDB, we must drop and rebuild the indices.
3566    drop_index_queries = list(flatten_list(
3567        [q for ix, q in self.get_drop_index_queries(pipe, debug=debug).items()]
3568    ))
3569    create_index_queries = list(flatten_list(
3570        [q for ix, q in self.get_create_index_queries(pipe, debug=debug).items()]
3571    ))
3572
3573    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]:
3576def get_alter_columns_queries(
3577    self,
3578    pipe: mrsm.Pipe,
3579    df: Union[pd.DataFrame, Dict[str, str]],
3580    debug: bool = False,
3581) -> List[str]:
3582    """
3583    If we encounter a column of a different type, set the entire column to text.
3584    If the altered columns are numeric, alter to numeric instead.
3585
3586    Parameters
3587    ----------
3588    pipe: mrsm.Pipe
3589        The pipe to be altered.
3590
3591    df: Union[pd.DataFrame, Dict[str, str]]
3592        The pandas DataFrame which may contain altered columns.
3593        If a dict is provided, assume it maps columns to Pandas data types.
3594
3595    Returns
3596    -------
3597    A list of the `ALTER TABLE` SQL query or queries to be executed on the provided connector.
3598    """
3599    if not pipe.exists(debug=debug) or pipe.static:
3600        return []
3601
3602    from meerschaum.utils.sql import (
3603        sql_item_name,
3604        get_table_cols_types,
3605        DROP_IF_EXISTS_FLAVORS,
3606        SINGLE_ALTER_TABLE_FLAVORS,
3607    )
3608    from meerschaum.utils.dataframe import get_numeric_cols
3609    from meerschaum.utils.dtypes import are_dtypes_equal
3610    from meerschaum.utils.dtypes.sql import (
3611        get_pd_type_from_db_type,
3612        get_db_type_from_pd_type,
3613    )
3614    from meerschaum.utils.misc import flatten_list, generate_password, items_str
3615    target = pipe.target
3616    session_id = generate_password(3)
3617    numeric_cols = (
3618        get_numeric_cols(df)
3619        if not isinstance(df, dict)
3620        else [
3621            col
3622            for col, typ in df.items()
3623            if typ.startswith('numeric')
3624        ]
3625    )
3626    df_cols_types = (
3627        {
3628            col: str(typ)
3629            for col, typ in df.dtypes.items()
3630        }
3631        if not isinstance(df, dict)
3632        else df
3633    )
3634    db_cols_types = {
3635        col: get_pd_type_from_db_type(typ)
3636        for col, typ in get_table_cols_types(
3637            pipe.target,
3638            self,
3639            schema=self.get_pipe_schema(pipe),
3640            debug=debug,
3641        ).items()
3642    }
3643    pipe_dtypes = pipe.get_dtypes(debug=debug)
3644    pipe_bool_cols = [col for col, typ in pipe_dtypes.items() if are_dtypes_equal(str(typ), 'bool')]
3645    pd_db_df_aliases = {
3646        'int': 'bool',
3647        'float': 'bool',
3648        'numeric': 'bool',
3649        'guid': 'object',
3650    }
3651    if self.flavor == 'oracle':
3652        pd_db_df_aliases.update({
3653            'int': 'numeric',
3654            'date': 'datetime',
3655            'numeric': 'int',
3656        })
3657    elif self.flavor == 'geopackage':
3658        pd_db_df_aliases.update({
3659            'geometry': 'bytes',
3660            'bytes': 'geometry',
3661        })
3662
3663    altered_cols = {
3664        col: (db_cols_types.get(col, 'object'), typ)
3665        for col, typ in df_cols_types.items()
3666        if not are_dtypes_equal(typ, db_cols_types.get(col, 'object').lower())
3667        and not are_dtypes_equal(db_cols_types.get(col, 'object'), 'string')
3668    }
3669
3670    if debug and altered_cols:
3671        dprint("Columns to be altered:")
3672        mrsm.pprint(altered_cols)
3673
3674    ### NOTE: Special columns (numerics, bools, etc.) are captured and cached upon detection.
3675    new_special_cols = pipe._get_cached_value('new_special_cols', debug=debug) or {}
3676    new_special_db_cols_types = {
3677        col: (db_cols_types.get(col, 'object'), typ)
3678        for col, typ in new_special_cols.items()
3679    }
3680    if debug:
3681        dprint("Cached new special columns:")
3682        mrsm.pprint(new_special_cols)
3683        dprint("New special columns db types:")
3684        mrsm.pprint(new_special_db_cols_types)
3685
3686    altered_cols.update(new_special_db_cols_types)
3687
3688    ### NOTE: Sometimes bools are coerced into ints or floats.
3689    altered_cols_to_ignore = set()
3690    for col, (db_typ, df_typ) in altered_cols.items():
3691        for db_alias, df_alias in pd_db_df_aliases.items():
3692            if (
3693                db_alias in db_typ.lower()
3694                and df_alias in df_typ.lower()
3695                and col not in new_special_cols
3696            ):
3697                altered_cols_to_ignore.add(col)
3698
3699    ### Oracle's bool handling sometimes mixes NUMBER and INT.
3700    for bool_col in pipe_bool_cols:
3701        if bool_col not in altered_cols:
3702            continue
3703        db_is_bool_compatible = (
3704            are_dtypes_equal('int', altered_cols[bool_col][0])
3705            or are_dtypes_equal('float', altered_cols[bool_col][0])
3706            or are_dtypes_equal('numeric', altered_cols[bool_col][0])
3707            or are_dtypes_equal('bool', altered_cols[bool_col][0])
3708        )
3709        df_is_bool_compatible = (
3710            are_dtypes_equal('int', altered_cols[bool_col][1])
3711            or are_dtypes_equal('float', altered_cols[bool_col][1])
3712            or are_dtypes_equal('numeric', altered_cols[bool_col][1])
3713            or are_dtypes_equal('bool', altered_cols[bool_col][1])
3714        )
3715        if db_is_bool_compatible and df_is_bool_compatible:
3716            altered_cols_to_ignore.add(bool_col)
3717
3718    if debug and altered_cols_to_ignore:
3719        dprint("Ignoring the following altered columns (false positives).")
3720        mrsm.pprint(altered_cols_to_ignore)
3721
3722    for col in altered_cols_to_ignore:
3723        _ = altered_cols.pop(col, None)
3724
3725    if not altered_cols:
3726        return []
3727
3728    if numeric_cols:
3729        explicit_pipe_dtypes = pipe.get_dtypes(infer=False, debug=debug)
3730        explicit_pipe_dtypes.update({col: 'numeric' for col in numeric_cols})
3731        pipe.dtypes = explicit_pipe_dtypes
3732        if not pipe.temporary:
3733            edit_success, edit_msg = pipe.edit(debug=debug)
3734            if not edit_success:
3735                warn(
3736                    f"Failed to update dtypes for numeric columns {items_str(numeric_cols)}:\n"
3737                    + f"{edit_msg}"
3738                )
3739    else:
3740        numeric_cols.extend([col for col, typ in pipe_dtypes.items() if typ.startswith('numeric')])
3741
3742    numeric_type = get_db_type_from_pd_type('numeric', self.flavor, as_sqlalchemy=False)
3743    text_type = get_db_type_from_pd_type('str', self.flavor, as_sqlalchemy=False)
3744    altered_cols_types = {
3745        col: (
3746            numeric_type
3747            if col in numeric_cols
3748            else text_type
3749        )
3750        for col, (db_typ, typ) in altered_cols.items()
3751    }
3752
3753    if self.flavor in ('sqlite', 'geopackage'):
3754        temp_table_name = '-' + session_id + '_' + target
3755        rename_query = (
3756            "ALTER TABLE "
3757            + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3758            + " RENAME TO "
3759            + sql_item_name(temp_table_name, self.flavor, None)
3760        )
3761        create_query = (
3762            "CREATE TABLE "
3763            + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3764            + " (\n"
3765        )
3766        for col_name, col_typ in db_cols_types.items():
3767            create_query += (
3768                sql_item_name(col_name, self.flavor, None)
3769                + " "
3770                + (
3771                    col_typ
3772                    if col_name not in altered_cols
3773                    else altered_cols_types[col_name]
3774                )
3775                + ",\n"
3776            )
3777        create_query = create_query[:-2] + "\n)"
3778
3779        insert_query = (
3780            "INSERT INTO "
3781            + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3782            + ' ('
3783            + ', '.join([
3784                sql_item_name(col_name, self.flavor, None)
3785                for col_name in db_cols_types
3786            ])
3787            + ')'
3788            + "\nSELECT\n"
3789        )
3790        for col_name in db_cols_types:
3791            new_col_str = (
3792                sql_item_name(col_name, self.flavor, None)
3793                if col_name not in altered_cols
3794                else (
3795                    "CAST("
3796                    + sql_item_name(col_name, self.flavor, None)
3797                    + " AS "
3798                    + altered_cols_types[col_name]
3799                    + ")"
3800                )
3801            )
3802            insert_query += new_col_str + ",\n"
3803
3804        insert_query = insert_query[:-2] + (
3805            f"\nFROM {sql_item_name(temp_table_name, self.flavor, self.get_pipe_schema(pipe))}"
3806        )
3807
3808        if_exists_str = "IF EXISTS" if self.flavor in DROP_IF_EXISTS_FLAVORS else ""
3809
3810        drop_query = f"DROP TABLE {if_exists_str}" + sql_item_name(
3811            temp_table_name, self.flavor, self.get_pipe_schema(pipe)
3812        )
3813        return [
3814            rename_query,
3815            create_query,
3816            insert_query,
3817            drop_query,
3818        ]
3819
3820    queries = []
3821    if self.flavor == 'oracle':
3822        for col, typ in altered_cols_types.items():
3823            add_query = (
3824                "ALTER TABLE "
3825                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3826                + "\nADD " + sql_item_name(col + '_temp', self.flavor, None)
3827                + " " + typ
3828            )
3829            queries.append(add_query)
3830
3831        for col, typ in altered_cols_types.items():
3832            populate_temp_query = (
3833                "UPDATE "
3834                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3835                + "\nSET " + sql_item_name(col + '_temp', self.flavor, None)
3836                + ' = ' + sql_item_name(col, self.flavor, None)
3837            )
3838            queries.append(populate_temp_query)
3839
3840        for col, typ in altered_cols_types.items():
3841            set_old_cols_to_null_query = (
3842                "UPDATE "
3843                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3844                + "\nSET " + sql_item_name(col, self.flavor, None)
3845                + ' = NULL'
3846            )
3847            queries.append(set_old_cols_to_null_query)
3848
3849        for col, typ in altered_cols_types.items():
3850            alter_type_query = (
3851                "ALTER TABLE "
3852                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3853                + "\nMODIFY " + sql_item_name(col, self.flavor, None) + ' '
3854                + typ
3855            )
3856            queries.append(alter_type_query)
3857
3858        for col, typ in altered_cols_types.items():
3859            set_old_to_temp_query = (
3860                "UPDATE "
3861                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3862                + "\nSET " + sql_item_name(col, self.flavor, None)
3863                + ' = ' + sql_item_name(col + '_temp', self.flavor, None)
3864            )
3865            queries.append(set_old_to_temp_query)
3866
3867        for col, typ in altered_cols_types.items():
3868            drop_temp_query = (
3869                "ALTER TABLE "
3870                + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3871                + "\nDROP COLUMN " + sql_item_name(col + '_temp', self.flavor, None)
3872            )
3873            queries.append(drop_temp_query)
3874
3875        return queries
3876
3877    query = "ALTER TABLE " + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe))
3878    for col, typ in altered_cols_types.items():
3879        alter_col_prefix = (
3880            'ALTER' if self.flavor not in ('mysql', 'mariadb', 'oracle')
3881            else 'MODIFY'
3882        )
3883        type_prefix = (
3884            '' if self.flavor in ('mssql', 'mariadb', 'mysql')
3885            else 'TYPE '
3886        )
3887        column_str = 'COLUMN' if self.flavor != 'oracle' else ''
3888        query_suffix = (
3889            f"\n{alter_col_prefix} {column_str} "
3890            + sql_item_name(col, self.flavor, None)
3891            + " " + type_prefix + typ + ","
3892        )
3893        if self.flavor not in SINGLE_ALTER_TABLE_FLAVORS:
3894            query += query_suffix
3895        else:
3896            queries.append(query + query_suffix[:-1])
3897
3898    if self.flavor not in SINGLE_ALTER_TABLE_FLAVORS:
3899        queries.append(query[:-1])
3900
3901    if self.flavor != 'duckdb':
3902        return queries
3903
3904    drop_index_queries = list(flatten_list(
3905        [q for ix, q in self.get_drop_index_queries(pipe, debug=debug).items()]
3906    ))
3907    create_index_queries = list(flatten_list(
3908        [q for ix, q in self.get_create_index_queries(pipe, debug=debug).items()]
3909    ))
3910
3911    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]:
1055def delete_pipe(
1056    self,
1057    pipe: mrsm.Pipe,
1058    debug: bool = False,
1059) -> SuccessTuple:
1060    """
1061    Delete a Pipe's registration.
1062    """
1063    from meerschaum.utils.packages import attempt_import
1064    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
1065
1066    if not pipe.id:
1067        return False, f"{pipe} is not registered."
1068
1069    ### ensure pipes table exists
1070    from meerschaum.connectors.sql.tables import get_tables
1071    pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes']
1072
1073    q = sqlalchemy.delete(pipes_tbl).where(pipes_tbl.c.pipe_id == pipe.id)
1074    if not self.exec(q, debug=debug):
1075        return False, f"Failed to delete registration for {pipe}."
1076
1077    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]':
1080def get_pipe_data(
1081    self,
1082    pipe: mrsm.Pipe,
1083    select_columns: Optional[List[str]] = None,
1084    omit_columns: Optional[List[str]] = None,
1085    begin: Union[datetime, str, None] = None,
1086    end: Union[datetime, str, None] = None,
1087    params: Optional[Dict[str, Any]] = None,
1088    order: str = 'asc',
1089    limit: Optional[int] = None,
1090    begin_add_minutes: int = 0,
1091    end_add_minutes: int = 0,
1092    chunksize: Optional[int] = -1,
1093    as_iterator: bool = False,
1094    as_polars: bool = False,
1095    debug: bool = False,
1096    **kw: Any
1097) -> Union[pd.DataFrame, None]:
1098    """
1099    Access a pipe's data from the SQL instance.
1100
1101    Parameters
1102    ----------
1103    pipe: mrsm.Pipe:
1104        The pipe to get data from.
1105
1106    select_columns: Optional[List[str]], default None
1107        If provided, only select these given columns.
1108        Otherwise select all available columns (i.e. `SELECT *`).
1109
1110    omit_columns: Optional[List[str]], default None
1111        If provided, remove these columns from the selection.
1112
1113    begin: Union[datetime, str, None], default None
1114        If provided, get rows newer than or equal to this value.
1115
1116    end: Union[datetime, str, None], default None
1117        If provided, get rows older than or equal to this value.
1118
1119    params: Optional[Dict[str, Any]], default None
1120        Additional parameters to filter by.
1121        See `meerschaum.connectors.sql.build_where`.
1122
1123    order: Optional[str], default 'asc'
1124        The selection order for all of the indices in the query.
1125        If `None`, omit the `ORDER BY` clause.
1126
1127    limit: Optional[int], default None
1128        If specified, limit the number of rows retrieved to this value.
1129
1130    begin_add_minutes: int, default 0
1131        The number of minutes to add to the `begin` datetime (i.e. `DATEADD`).
1132
1133    end_add_minutes: int, default 0
1134        The number of minutes to add to the `end` datetime (i.e. `DATEADD`).
1135
1136    chunksize: Optional[int], default -1
1137        The size of dataframe chunks to load into memory.
1138
1139    as_iterator: bool, default False
1140        If `True`, return the chunks iterator directly.
1141
1142    as_polars: bool, default False
1143        If `True`, use an Arrow-native driver when available and return a Polars DataFrame.
1144
1145    debug: bool, default False
1146        Verbosity toggle.
1147
1148    Returns
1149    -------
1150    A Pandas DataFrame, or a Polars DataFrame when an Arrow-native read is available.
1151
1152    """
1153    from meerschaum.utils.packages import import_pandas
1154    from meerschaum.utils.dtypes import to_pandas_dtype, are_dtypes_equal
1155    from meerschaum.utils.dtypes.sql import get_pd_type_from_db_type
1156    pd = import_pandas()
1157    is_dask = 'dask' in pd.__name__
1158
1159    cols_types = pipe.get_columns_types(debug=debug) if pipe.enforce else {}
1160    pipe_dtypes = pipe.get_dtypes(infer=False, debug=debug) if pipe.enforce else {}
1161
1162    remote_pandas_types = {
1163        col: to_pandas_dtype(get_pd_type_from_db_type(typ))
1164        for col, typ in cols_types.items()
1165    }
1166    remote_dt_cols_types = {
1167        col: typ
1168        for col, typ in remote_pandas_types.items()
1169        if are_dtypes_equal(typ, 'datetime')
1170    }
1171    remote_dt_tz_aware_cols_types = {
1172        col: typ
1173        for col, typ in remote_dt_cols_types.items()
1174        if ',' in typ or typ == 'datetime'
1175    }
1176    remote_dt_tz_naive_cols_types = {
1177        col: typ
1178        for col, typ in remote_dt_cols_types.items()
1179        if col not in remote_dt_tz_aware_cols_types
1180    }
1181
1182    configured_pandas_types = {
1183        col: to_pandas_dtype(typ)
1184        for col, typ in pipe_dtypes.items()
1185    }
1186    configured_lower_precision_dt_cols_types = {
1187        col: typ
1188        for col, typ in pipe_dtypes.items()
1189        if (
1190            are_dtypes_equal('datetime', typ)
1191            and '[' in typ
1192            and 'ns' not in typ
1193        )
1194        
1195    }
1196
1197    dtypes = {
1198        **remote_pandas_types,
1199        **configured_pandas_types,
1200        **remote_dt_tz_aware_cols_types,
1201        **remote_dt_tz_naive_cols_types,
1202        **configured_lower_precision_dt_cols_types
1203    } if pipe.enforce else {}
1204
1205    existing_cols = cols_types.keys()
1206    select_columns = (
1207        [
1208            col
1209            for col in existing_cols
1210            if col not in (omit_columns or [])
1211        ]
1212        if not select_columns
1213        else [
1214            col
1215            for col in select_columns
1216            if col in existing_cols
1217            and col not in (omit_columns or [])
1218        ]
1219    ) if pipe.enforce else select_columns
1220
1221    if select_columns:
1222        dtypes = {col: typ for col, typ in dtypes.items() if col in select_columns}
1223
1224    dtypes = {
1225        col: typ
1226        for col, typ in dtypes.items()
1227        if col in (select_columns or [col]) and col not in (omit_columns or [])
1228    } if pipe.enforce else {}
1229
1230    if debug:
1231        dprint(f"[{self}] `read()` dtypes:")
1232        mrsm.pprint(dtypes)
1233
1234    query = self.get_pipe_data_query(
1235        pipe,
1236        select_columns=select_columns,
1237        omit_columns=omit_columns,
1238        begin=begin,
1239        end=end,
1240        params=params,
1241        order=order,
1242        limit=limit,
1243        begin_add_minutes=begin_add_minutes,
1244        end_add_minutes=end_add_minutes,
1245        debug=debug,
1246        **kw
1247    )
1248
1249    arrow_dtypes = {
1250        col: pipe_dtypes.get(col, get_pd_type_from_db_type(cols_types.get(col, '')))
1251        for col in dtypes
1252    }
1253    fallback_dtypes = {
1254        col: typ
1255        for col, typ in arrow_dtypes.items()
1256        if (
1257            str(typ).lower() in ('uuid', 'object', 'numeric')
1258            or str(typ).lower().startswith(('geometry', 'geography'))
1259        )
1260    }
1261    adbc_driver = {
1262        'sqlite': 'adbc_driver_sqlite',
1263        'postgresql': 'adbc_driver_postgresql',
1264        'postgis': 'adbc_driver_postgresql',
1265        'timescaledb': 'adbc_driver_postgresql',
1266        'timescaledb-ha': 'adbc_driver_postgresql',
1267        'citus': 'adbc_driver_postgresql',
1268        'cockroachdb': 'adbc_driver_postgresql',
1269    }.get(self.flavor, None)
1270    can_read_native = bool(
1271        as_polars
1272        and pipe.enforce
1273        and dtypes
1274        and not fallback_dtypes
1275        and not as_iterator
1276        and not is_dask
1277        and (
1278            self.flavor == 'duckdb'
1279            or (adbc_driver and not (self.flavor == 'sqlite' and self.database == ':memory:'))
1280        )
1281    )
1282    if can_read_native:
1283        from meerschaum.utils.packages import attempt_import
1284        polars = attempt_import('polars', lazy=False)
1285        try:
1286            if self.flavor == 'duckdb':
1287                with self.engine.connect() as connection:
1288                    return connection.connection.driver_connection.execute(query).pl()
1289            if attempt_import(
1290                adbc_driver,
1291                lazy=False,
1292                warn=False,
1293                install=False,
1294            ) is not None:
1295                import warnings
1296                adbc_uri = self.URI
1297                if adbc_driver == 'adbc_driver_postgresql':
1298                    adbc_uri = 'postgresql://' + adbc_uri.split('://', maxsplit=1)[-1]
1299                with warnings.catch_warnings():
1300                    warnings.filterwarnings(
1301                        'ignore',
1302                        message=r"Extension type 'arrow\.(json|opaque)' is not registered",
1303                    )
1304                    return polars.read_database_uri(query, adbc_uri, engine='adbc')
1305        except Exception as e:
1306            if debug:
1307                dprint(f"[{self}] Arrow-native read failed; falling back to Pandas:\n{e}")
1308
1309    read_kwargs = {}
1310    if is_dask:
1311        index_col = pipe.columns.get('datetime', None)
1312        read_kwargs['index_col'] = index_col
1313
1314    chunks = self.read(
1315        query,
1316        chunksize=chunksize,
1317        as_iterator=True,
1318        coerce_float=False,
1319        dtype=dtypes,
1320        debug=debug,
1321        **read_kwargs
1322    )
1323
1324    if as_iterator:
1325        return chunks
1326
1327    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]]:
1330def get_pipe_docs(
1331    self,
1332    pipe: mrsm.Pipe,
1333    select_columns: Optional[List[str]] = None,
1334    omit_columns: Optional[List[str]] = None,
1335    begin: Union[datetime, str, None] = None,
1336    end: Union[datetime, str, None] = None,
1337    params: Optional[Dict[str, Any]] = None,
1338    order: str = 'asc',
1339    limit: Optional[int] = None,
1340    debug: bool = False,
1341    **kw: Any
1342) -> List[Dict[str, Any]]:
1343    """
1344    Return a pipe's data as a list of dictionaries, bypassing pandas overhead.
1345    """
1346    query = self.get_pipe_data_query(
1347        pipe=pipe,
1348        select_columns=select_columns,
1349        omit_columns=omit_columns,
1350        begin=begin,
1351        end=end,
1352        params=params,
1353        order=order,
1354        limit=limit,
1355        debug=debug,
1356    )
1357    if query is None:
1358        return []
1359    result = self.exec(query, silent=True, debug=debug)
1360    if result is None:
1361        return []
1362    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]:
1365def get_pipe_data_query(
1366    self,
1367    pipe: mrsm.Pipe,
1368    select_columns: Optional[List[str]] = None,
1369    omit_columns: Optional[List[str]] = None,
1370    begin: Union[datetime, int, str, None] = None,
1371    end: Union[datetime, int, str, None] = None,
1372    params: Optional[Dict[str, Any]] = None,
1373    order: Optional[str] = 'asc',
1374    sort_datetimes: bool = False,
1375    limit: Optional[int] = None,
1376    begin_add_minutes: int = 0,
1377    end_add_minutes: int = 0,
1378    replace_nulls: Optional[str] = None,
1379    skip_existing_cols_check: bool = False,
1380    debug: bool = False,
1381    **kw: Any
1382) -> Union[str, None]:
1383    """
1384    Return the `SELECT` query for retrieving a pipe's data from its instance.
1385
1386    Parameters
1387    ----------
1388    pipe: mrsm.Pipe:
1389        The pipe to get data from.
1390
1391    select_columns: Optional[List[str]], default None
1392        If provided, only select these given columns.
1393        Otherwise select all available columns (i.e. `SELECT *`).
1394
1395    omit_columns: Optional[List[str]], default None
1396        If provided, remove these columns from the selection.
1397
1398    begin: Union[datetime, int, str, None], default None
1399        If provided, get rows newer than or equal to this value.
1400
1401    end: Union[datetime, str, None], default None
1402        If provided, get rows older than or equal to this value.
1403
1404    params: Optional[Dict[str, Any]], default None
1405        Additional parameters to filter by.
1406        See `meerschaum.connectors.sql.build_where`.
1407
1408    order: Optional[str], default None
1409        The selection order for all of the indices in the query.
1410        If `None`, omit the `ORDER BY` clause.
1411
1412    sort_datetimes: bool, default False
1413        Alias for `order='desc'`.
1414
1415    limit: Optional[int], default None
1416        If specified, limit the number of rows retrieved to this value.
1417
1418    begin_add_minutes: int, default 0
1419        The number of minutes to add to the `begin` datetime (i.e. `DATEADD`).
1420
1421    end_add_minutes: int, default 0
1422        The number of minutes to add to the `end` datetime (i.e. `DATEADD`).
1423
1424    chunksize: Optional[int], default -1
1425        The size of dataframe chunks to load into memory.
1426
1427    replace_nulls: Optional[str], default None
1428        If provided, replace null values with this value.
1429
1430    skip_existing_cols_check: bool, default False
1431        If `True`, do not verify that querying columns are actually on the table.
1432
1433    debug: bool, default False
1434        Verbosity toggle.
1435
1436    Returns
1437    -------
1438    A `SELECT` query to retrieve a pipe's data.
1439    """
1440    from meerschaum.utils.misc import items_str
1441    from meerschaum.utils.sql import sql_item_name, dateadd_str
1442    from meerschaum.utils.dtypes import coerce_timezone
1443    from meerschaum.utils.dtypes.sql import get_pd_type_from_db_type, get_db_type_from_pd_type
1444
1445    dt_col = pipe.columns.get('datetime', None)
1446    existing_cols = pipe.get_columns_types(debug=debug) if pipe.enforce else []
1447    skip_existing_cols_check = skip_existing_cols_check or not pipe.enforce
1448    dt_typ = get_pd_type_from_db_type(existing_cols[dt_col]) if dt_col in existing_cols else None
1449    dt_db_type = get_db_type_from_pd_type(dt_typ, self.flavor) if dt_typ else None
1450    select_columns = (
1451        [col for col in existing_cols]
1452        if not select_columns
1453        else [col for col in select_columns if skip_existing_cols_check or col in existing_cols]
1454    )
1455    if omit_columns:
1456        select_columns = [col for col in select_columns if col not in omit_columns]
1457
1458    if order is None and sort_datetimes:
1459        order = 'desc'
1460
1461    if begin == '':
1462        begin = pipe.get_sync_time(debug=debug)
1463        backtrack_interval = pipe.get_backtrack_interval(debug=debug)
1464        if begin is not None:
1465            begin -= backtrack_interval
1466
1467    begin, end = pipe.parse_date_bounds(begin, end)
1468    if isinstance(begin, datetime) and dt_typ:
1469        begin = coerce_timezone(begin, strip_utc=('utc' not in dt_typ.lower()))
1470    if isinstance(end, datetime) and dt_typ:
1471        end = coerce_timezone(end, strip_utc=('utc' not in dt_typ.lower()))
1472
1473    cols_names = [
1474        sql_item_name(col, self.flavor, None)
1475        for col in select_columns
1476    ]
1477    select_cols_str = (
1478        'SELECT\n    '
1479        + ',\n    '.join(
1480            [
1481                (
1482                    col_name
1483                    if not replace_nulls
1484                    else f"COALESCE(col_name, '{replace_nulls}') AS {col_name}"
1485                )
1486                for col_name in cols_names
1487            ]
1488        )
1489    ) if cols_names else 'SELECT *'
1490    pipe_table_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
1491    query = f"{select_cols_str}\nFROM {pipe_table_name}"
1492    where = ""
1493
1494    ### MariaDB 12.x optimizer bug: an ordered index scan with `LIMIT` over a `RANGE COLUMNS`-
1495    ### partitioned table that must read a non-indexed column returns zero rows (see the
1496    ### `get_sync_time` workaround in CLAUDE.md — same family, MariaDB-only). Wrapping the
1497    ### datetime column in the `ORDER BY` with a no-op `COALESCE(col, col)` forces a filesort
1498    ### over the fetched rows instead of the broken index walk, with identical ordering. Gated
1499    ### on `LIMIT` (the bug's trigger) so unlimited ordered reads keep the index-ordered scan.
1500    mariadb_partition_order_workaround = (
1501        self.flavor == 'mariadb'
1502        and isinstance(limit, int)
1503        and self._should_partition(pipe)
1504    )
1505
1506    if order is not None:
1507        default_order = 'asc'
1508        if order not in ('asc', 'desc'):
1509            warn(f"Ignoring unsupported order '{order}'. Falling back to '{default_order}'.")
1510            order = default_order
1511        order = order.upper()
1512
1513    if not pipe.columns.get('datetime', None):
1514        _dt = pipe.guess_datetime()
1515        dt = sql_item_name(_dt, self.flavor, None) if _dt else None
1516        is_guess = True
1517    else:
1518        _dt = pipe.get_columns('datetime')
1519        dt = sql_item_name(_dt, self.flavor, None)
1520        is_guess = False
1521
1522    quoted_indices = {
1523        key: sql_item_name(val, self.flavor, None)
1524        for key, val in pipe.columns.items()
1525        if val in existing_cols or skip_existing_cols_check
1526    }
1527
1528    if begin is not None or end is not None:
1529        if is_guess:
1530            if _dt is None:
1531                warn(
1532                    f"No datetime could be determined for {pipe}."
1533                    + "\n    Ignoring begin and end...",
1534                    stack=False,
1535                )
1536                begin, end = None, None
1537            else:
1538                warn(
1539                    f"A datetime wasn't specified for {pipe}.\n"
1540                    + f"    Using column \"{_dt}\" for datetime bounds...",
1541                    stack=False,
1542                )
1543
1544    is_dt_bound = False
1545    if begin is not None and (_dt in existing_cols or skip_existing_cols_check):
1546        begin_da = dateadd_str(
1547            flavor=self.flavor,
1548            datepart='minute',
1549            number=begin_add_minutes,
1550            begin=begin,
1551            db_type=dt_db_type,
1552        )
1553        where += f"\n    {dt} >= {begin_da}" + ("\n    AND\n    " if end is not None else "")
1554        is_dt_bound = True
1555
1556    if end is not None and (_dt in existing_cols or skip_existing_cols_check):
1557        if 'int' in str(type(end)).lower() and end == begin:
1558            end += 1
1559        end_da = dateadd_str(
1560            flavor=self.flavor,
1561            datepart='minute',
1562            number=end_add_minutes,
1563            begin=end,
1564            db_type=dt_db_type,
1565        )
1566        where += f"{dt} <  {end_da}"
1567        is_dt_bound = True
1568
1569    if params is not None:
1570        from meerschaum.utils.sql import build_where
1571        valid_params = {
1572            k: v
1573            for k, v in params.items()
1574            if k in existing_cols or skip_existing_cols_check
1575        }
1576        if valid_params:
1577            where += '    ' + build_where(valid_params, self).lstrip().replace(
1578                'WHERE', ('    AND' if is_dt_bound else "    ")
1579            )
1580
1581    if len(where) > 0:
1582        query += "\nWHERE " + where
1583
1584    if order is not None:
1585        ### Sort by indices, starting with datetime.
1586        order_by = ""
1587        if quoted_indices:
1588            order_by += "\nORDER BY "
1589            if _dt and (_dt in existing_cols or skip_existing_cols_check):
1590                dt_order_expr = (
1591                    f"COALESCE({dt}, {dt})"
1592                    if mariadb_partition_order_workaround
1593                    else dt
1594                )
1595                order_by += dt_order_expr + ' ' + order + ','
1596            for key, quoted_col_name in quoted_indices.items():
1597                if dt == quoted_col_name:
1598                    continue
1599                order_by += ' ' + quoted_col_name + ' ' + order + ','
1600            order_by = order_by[:-1]
1601
1602        query += order_by
1603
1604    if isinstance(limit, int):
1605        if self.flavor == 'mssql':
1606            query = f'SELECT TOP {limit}\n' + query[len("SELECT "):]
1607        elif self.flavor == 'oracle':
1608            query = (
1609                f"SELECT * FROM (\n  {query}\n)\n"
1610                + f"WHERE ROWNUM IN ({', '.join([str(i) for i in range(1, limit+1)])})"
1611            )
1612        else:
1613            query += f"\nLIMIT {limit}"
1614
1615    if debug:
1616        to_print = (
1617            []
1618            + ([f"begin='{begin}'"] if begin else [])
1619            + ([f"end='{end}'"] if end else [])
1620            + ([f"params={params}"] if params else [])
1621        )
1622        dprint("Getting pipe data with constraints: " + items_str(to_print, quotes=False))
1623
1624    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]:
21def register_pipe(
22    self,
23    pipe: mrsm.Pipe,
24    debug: bool = False,
25) -> SuccessTuple:
26    """
27    Register a new pipe.
28    A pipe's attributes must be set before registering.
29    """
30    from meerschaum.utils.packages import attempt_import
31    from meerschaum.utils.sql import json_flavors
32
33    ### ensure pipes table exists
34    from meerschaum.connectors.sql.tables import get_tables
35    pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes']
36
37    if pipe.id is not None:
38        return False, f"{pipe} is already registered."
39
40    ### NOTE: if `parameters` is supplied in the Pipe constructor,
41    ###       then `pipe.parameters` will exist and not be fetched from the database.
42
43    ### 1. Prioritize the Pipe object's `parameters` first.
44    ###    E.g. if the user manually sets the `parameters` property
45    ###    or if the Pipe already exists
46    ###    (which shouldn't be able to be registered anyway but that's an issue for later).
47    parameters = None
48    try:
49        parameters = pipe.get_parameters(apply_symlinks=False)
50    except Exception as e:
51        if debug:
52            dprint(str(e))
53        parameters = None
54
55    ### ensure `parameters` is a dictionary
56    if parameters is None:
57        parameters = {}
58
59    import json
60    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
61    values = {
62        'connector_keys' : pipe.connector_keys,
63        'metric_key'     : pipe.metric_key,
64        'location_key'   : pipe.location_key,
65        'parameters'     : (
66            json.dumps(parameters)
67            if self.flavor not in json_flavors
68            else parameters
69        ),
70    }
71    query = sqlalchemy.insert(pipes_tbl).values(**values)
72    result = self.exec(query, debug=debug)
73    if result is None:
74        return False, f"Failed to register {pipe}."
75    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]:
 78def edit_pipe(
 79    self,
 80    pipe: mrsm.Pipe,
 81    patch: bool = False,
 82    debug: bool = False,
 83    **kw : Any
 84) -> SuccessTuple:
 85    """
 86    Persist a Pipe's parameters to its database.
 87
 88    Parameters
 89    ----------
 90    pipe: mrsm.Pipe, default None
 91        The pipe to be edited.
 92    patch: bool, default False
 93        If patch is `True`, update the existing parameters by cascading.
 94        Otherwise overwrite the parameters (default).
 95    debug: bool, default False
 96        Verbosity toggle.
 97    """
 98
 99    if pipe.id is None:
100        return False, f"{pipe} is not registered and cannot be edited."
101
102    from meerschaum.utils.packages import attempt_import
103    from meerschaum.utils.sql import json_flavors
104    if not patch:
105        parameters = pipe.__dict__.get('_attributes', {}).get('parameters', {})
106    else:
107        from meerschaum import Pipe
108        from meerschaum.config._patch import apply_patch_to_config
109        original_parameters = Pipe(
110            pipe.connector_keys, pipe.metric_key, pipe.location_key,
111            mrsm_instance=pipe.instance_keys
112        ).get_parameters(apply_symlinks=False)
113        parameters = apply_patch_to_config(
114            original_parameters,
115            pipe._attributes['parameters']
116        )
117
118    ### ensure pipes table exists
119    from meerschaum.connectors.sql.tables import get_tables
120    pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes']
121
122    import json
123    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
124
125    values = {
126        'parameters': (
127            json.dumps(parameters)
128            if self.flavor not in json_flavors
129            else parameters
130        ),
131    }
132    q = sqlalchemy.update(pipes_tbl).values(**values).where(
133        pipes_tbl.c.pipe_id == pipe.id
134    )
135
136    result = self.exec(q, debug=debug)
137    message = (
138        f"Successfully edited {pipe}."
139        if result is not None else f"Failed to edit {pipe}."
140    )
141    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:
1627def get_pipe_id(
1628    self,
1629    pipe: mrsm.Pipe,
1630    debug: bool = False,
1631) -> Any:
1632    """
1633    Get a Pipe's ID from the pipes table.
1634    """
1635    if pipe.temporary:
1636        return None
1637    from meerschaum.utils.packages import attempt_import
1638    sqlalchemy = attempt_import('sqlalchemy')
1639    from meerschaum.connectors.sql.tables import get_tables
1640    pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes']
1641
1642    query = sqlalchemy.select(pipes_tbl.c.pipe_id).where(
1643        pipes_tbl.c.connector_keys == pipe.connector_keys
1644    ).where(
1645        pipes_tbl.c.metric_key == pipe.metric_key
1646    ).where(
1647        (pipes_tbl.c.location_key == pipe.location_key) if pipe.location_key is not None
1648        else pipes_tbl.c.location_key.is_(None)
1649    )
1650    _id = self.value(query, debug=debug, silent=pipe.temporary)
1651    if _id is not None:
1652        _id = int(_id)
1653    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]:
1656def get_pipe_attributes(
1657    self,
1658    pipe: mrsm.Pipe,
1659    debug: bool = False,
1660) -> Dict[str, Any]:
1661    """
1662    Get a Pipe's attributes dictionary.
1663    """
1664    from meerschaum.connectors.sql.tables import get_tables
1665    from meerschaum.utils.packages import attempt_import
1666    sqlalchemy = attempt_import('sqlalchemy', lazy=False)
1667
1668    if pipe.id is None:
1669        return {}
1670
1671    pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes']
1672
1673    try:
1674        q = sqlalchemy.select(pipes_tbl).where(pipes_tbl.c.pipe_id == pipe.id)
1675        if debug:
1676            dprint(q)
1677        rows = (
1678            self.exec(q, silent=True, debug=debug).mappings().all()
1679            if self.flavor != 'duckdb'
1680            else self.read(q, debug=debug).to_dict(orient='records')
1681        )
1682        if not rows:
1683            return {}
1684        attributes = dict(rows[0])
1685    except Exception:
1686        if debug:
1687            dprint(traceback.format_exc())
1688        return {}
1689
1690    ### handle non-PostgreSQL databases (text vs JSON)
1691    if not isinstance(attributes.get('parameters', None), dict):
1692        try:
1693            import json
1694            parameters = json.loads(attributes['parameters'])
1695            if isinstance(parameters, str) and parameters[0] == '{':
1696                parameters = json.loads(parameters)
1697            attributes['parameters'] = parameters
1698        except Exception:
1699            attributes['parameters'] = {}
1700
1701    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]:
1876def sync_pipe(
1877    self,
1878    pipe: mrsm.Pipe,
1879    df: Union[pd.DataFrame, str, Dict[Any, Any], None] = None,
1880    begin: Union[datetime, int, None] = None,
1881    end: Union[datetime, int, None] = None,
1882    chunksize: Optional[int] = -1,
1883    check_existing: bool = True,
1884    blocking: bool = True,
1885    debug: bool = False,
1886    _check_temporary_tables: bool = True,
1887    **kw: Any
1888) -> SuccessTuple:
1889    """
1890    Sync a pipe using a database connection.
1891
1892    Parameters
1893    ----------
1894    pipe: mrsm.Pipe
1895        The Meerschaum Pipe instance into which to sync the data.
1896
1897    df: Union[pandas.DataFrame, str, Dict[Any, Any], List[Dict[str, Any]]]
1898        An optional DataFrame or equivalent to sync into the pipe.
1899        Defaults to `None`.
1900
1901    begin: Union[datetime, int, None], default None
1902        Optionally specify the earliest datetime to search for data.
1903        Defaults to `None`.
1904
1905    end: Union[datetime, int, None], default None
1906        Optionally specify the latest datetime to search for data.
1907        Defaults to `None`.
1908
1909    chunksize: Optional[int], default -1
1910        Specify the number of rows to sync per chunk.
1911        If `-1`, resort to system configuration (default is `900`).
1912        A `chunksize` of `None` will sync all rows in one transaction.
1913        Defaults to `-1`.
1914
1915    check_existing: bool, default True
1916        If `True`, pull and diff with existing data from the pipe. Defaults to `True`.
1917
1918    blocking: bool, default True
1919        If `True`, wait for sync to finish and return its result, otherwise asyncronously sync.
1920        Defaults to `True`.
1921
1922    debug: bool, default False
1923        Verbosity toggle. Defaults to False.
1924
1925    kw: Any
1926        Catch-all for keyword arguments.
1927
1928    Returns
1929    -------
1930    A `SuccessTuple` of success (`bool`) and message (`str`).
1931    """
1932    from meerschaum.utils.packages import import_pandas
1933    from meerschaum.utils.sql import (
1934        get_update_queries,
1935        sql_item_name,
1936        UPDATE_QUERIES,
1937        get_reset_autoincrement_queries,
1938    )
1939    from meerschaum.utils.dtypes import get_current_timestamp
1940    from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type
1941    from meerschaum.utils.dataframe import get_special_cols
1942    from meerschaum import Pipe
1943    import time
1944    import copy
1945    pd = import_pandas()
1946    if df is None:
1947        msg = f"DataFrame is None. Cannot sync {pipe}."
1948        warn(msg)
1949        return False, msg
1950
1951    start = time.perf_counter()
1952    pipe_name = sql_item_name(pipe.target, self.flavor, schema=self.get_pipe_schema(pipe))
1953    dtypes = pipe.get_dtypes(debug=debug)
1954
1955    if not pipe.temporary and not pipe.id:
1956        register_tuple = pipe.register(debug=debug)
1957        if not register_tuple[0]:
1958            return register_tuple
1959
1960    ### df is the dataframe returned from the remote source
1961    ### via the connector
1962    if debug:
1963        dprint("Fetched data:\n" + str(df))
1964
1965    if not isinstance(df, pd.DataFrame):
1966        df = pipe.enforce_dtypes(
1967            df,
1968            chunksize=chunksize,
1969            safe_copy=kw.get('safe_copy', False),
1970            dtypes=dtypes,
1971            debug=debug,
1972        )
1973
1974    ### if table does not exist, create it with indices
1975    is_new = False
1976    if not pipe.exists(debug=debug):
1977        check_existing = False
1978        is_new = True
1979    else:
1980        ### Check for new columns.
1981        add_cols_queries = self.get_add_columns_queries(pipe, df, debug=debug)
1982        if add_cols_queries:
1983            pipe._clear_cache_key('_columns_types', debug=debug)
1984            pipe._clear_cache_key('_columns_indices', debug=debug)
1985            if not self.exec_queries(add_cols_queries, debug=debug):
1986                warn(f"Failed to add new columns to {pipe}.")
1987
1988        alter_cols_queries = self.get_alter_columns_queries(pipe, df, debug=debug)
1989        if alter_cols_queries:
1990            pipe._clear_cache_key('_columns_types', debug=debug)
1991            pipe._clear_cache_key('_columns_types', debug=debug)
1992            if not self.exec_queries(alter_cols_queries, debug=debug):
1993                warn(f"Failed to alter columns for {pipe}.")
1994
1995    upsert = pipe.parameters.get('upsert', False) and (self.flavor + '-upsert') in UPDATE_QUERIES
1996    if upsert:
1997        check_existing = False
1998    kw['safe_copy'] = kw.get('safe_copy', False)
1999
2000    unseen_df, update_df, delta_df = (
2001        pipe.filter_existing(
2002            df,
2003            chunksize=chunksize,
2004            debug=debug,
2005            **kw
2006        ) if check_existing else (df, None, df)
2007    )
2008    if upsert:
2009        unseen_df, update_df, delta_df = (df.head(0), df, df)
2010
2011    if debug:
2012        dprint("Delta data:\n" + str(delta_df))
2013        dprint("Unseen data:\n" + str(unseen_df))
2014        if update_df is not None:
2015            dprint(("Update" if not upsert else "Upsert") + " data:\n" + str(update_df))
2016
2017    if_exists = kw.get('if_exists', 'append')
2018    if 'if_exists' in kw:
2019        kw.pop('if_exists')
2020    if 'name' in kw:
2021        kw.pop('name')
2022
2023    ### Insert new data into the target table.
2024    unseen_kw = copy.deepcopy(kw)
2025    unseen_kw.update({
2026        'name': pipe.target,
2027        'if_exists': if_exists,
2028        'debug': debug,
2029        'as_dict': True,
2030        'safe_copy': kw.get('safe_copy', False),
2031        'chunksize': chunksize,
2032        'dtype': self.get_to_sql_dtype(pipe, unseen_df, update_dtypes=True),
2033        'schema': self.get_pipe_schema(pipe),
2034    })
2035
2036    dt_col = pipe.columns.get('datetime', None)
2037    primary_key = pipe.columns.get('primary', None)
2038    autoincrement = (
2039        pipe.parameters.get('autoincrement', False)
2040        or (
2041            is_new
2042            and primary_key
2043            and primary_key
2044            not in dtypes
2045            and primary_key not in unseen_df.columns
2046        )
2047    )
2048    if autoincrement and autoincrement not in pipe.parameters:
2049        update_success, update_msg = pipe.update_parameters(
2050            {'autoincrement': autoincrement},
2051            debug=debug,
2052        )
2053        if not update_success:
2054            return update_success, update_msg
2055
2056    def _check_pk(_df_to_clear):
2057        if _df_to_clear is None:
2058            return
2059        if primary_key not in _df_to_clear.columns:
2060            return
2061        if not _df_to_clear[primary_key].notnull().any():
2062            del _df_to_clear[primary_key]
2063
2064    autoincrement_needs_reset = bool(
2065        autoincrement
2066        and primary_key
2067        and primary_key in unseen_df.columns
2068        and unseen_df[primary_key].notnull().any()
2069    )
2070    if autoincrement and primary_key:
2071        for _df_to_clear in (unseen_df, update_df, delta_df):
2072            _check_pk(_df_to_clear)
2073
2074    if is_new:
2075        create_success, create_msg = self.create_pipe_table_from_df(
2076            pipe,
2077            unseen_df,
2078            debug=debug,
2079        )
2080        if not create_success:
2081            return create_success, create_msg
2082
2083    ### Pre-create native range partitions (non-TimescaleDB) so the rows about to be written
2084    ### land in an existing partition. No-op for non-partitioned pipes.
2085    if self._should_partition(pipe):
2086        for _part_df in (unseen_df, update_df):
2087            if _part_df is not None and len(_part_df) > 0:
2088                part_success, part_msg = self._create_missing_partitions(
2089                    pipe, _part_df, debug=debug,
2090                )
2091                if not part_success:
2092                    return part_success, part_msg
2093
2094    do_identity_insert = bool(
2095        self.flavor in ('mssql',)
2096        and primary_key
2097        and primary_key in unseen_df.columns
2098        and autoincrement
2099    )
2100    stats = {'success': True, 'msg': ''}
2101    if len(unseen_df) > 0:
2102        with self.engine.connect() as connection:
2103            with connection.begin():
2104                if do_identity_insert:
2105                    identity_on_result = self.exec(
2106                        f"SET IDENTITY_INSERT {pipe_name} ON",
2107                        commit=False,
2108                        _connection=connection,
2109                        close=False,
2110                        debug=debug,
2111                    )
2112                    if identity_on_result is None:
2113                        return False, f"Could not enable identity inserts on {pipe}."
2114
2115                stats = self.to_sql(
2116                    unseen_df,
2117                    _connection=connection,
2118                    **unseen_kw
2119                )
2120
2121                if do_identity_insert:
2122                    identity_off_result = self.exec(
2123                        f"SET IDENTITY_INSERT {pipe_name} OFF",
2124                        commit=False,
2125                        _connection=connection,
2126                        close=False,
2127                        debug=debug,
2128                    )
2129                    if identity_off_result is None:
2130                        return False, f"Could not disable identity inserts on {pipe}."
2131
2132    if is_new:
2133        if not self.create_indices(pipe, debug=debug):
2134            warn(f"Failed to create indices for {pipe}. Continuing...")
2135
2136    if autoincrement_needs_reset:
2137        reset_autoincrement_queries = get_reset_autoincrement_queries(
2138            pipe.target,
2139            primary_key,
2140            self,
2141            schema=self.get_pipe_schema(pipe),
2142            debug=debug,
2143        )
2144        results = self.exec_queries(reset_autoincrement_queries, debug=debug)
2145        for result in results:
2146            if result is None:
2147                warn(f"Could not reset auto-incrementing primary key for {pipe}.", stack=False)
2148
2149    if update_df is not None and len(update_df) > 0:
2150        temp_target = self.get_temporary_target(
2151            pipe.target,
2152            label=('update' if not upsert else 'upsert'),
2153        )
2154        self._log_temporary_tables_creation(temp_target, create=(not pipe.temporary), debug=debug)
2155        pipe_dtypes = pipe.dtypes
2156        update_dtypes = {
2157            **{
2158                col: str(typ)
2159                for col, typ in update_df.dtypes.items()
2160            },
2161            **get_special_cols(update_df),
2162            **{
2163                col: pipe_dtypes[col]
2164                for col in update_df.columns
2165                if col in pipe_dtypes and update_df[col].isnull().all()
2166            },
2167        }
2168
2169        temp_pipe = Pipe(
2170            pipe.connector_keys.replace(':', '_') + '_', pipe.metric_key, pipe.location_key,
2171            instance=pipe.instance_keys,
2172            columns={
2173                (ix_key if ix_key != 'primary' else 'primary_'): ix
2174                for ix_key, ix in pipe.columns.items()
2175                if ix and ix in update_df.columns
2176            },
2177            dtypes=update_dtypes,
2178            target=temp_target,
2179            temporary=True,
2180            enforce=False,
2181            static=True,
2182            autoincrement=False,
2183            cache=False,
2184            parameters={
2185                'schema': self.internal_schema,
2186                'hypertable': False,
2187            },
2188        )
2189        _temp_columns_types = {
2190            col: get_db_type_from_pd_type(typ, self.flavor)
2191            for col, typ in update_dtypes.items()
2192        }
2193        temp_pipe._cache_value('_columns_types', _temp_columns_types, memory_only=True, debug=debug)
2194        temp_pipe._cache_value('_skip_check_indices', True, memory_only=True, debug=debug)
2195        now_ts = get_current_timestamp('ms', as_int=True) / 1000
2196        temp_pipe._cache_value('_columns_types_timestamp', now_ts, memory_only=True, debug=debug)
2197        temp_success, temp_msg = temp_pipe.sync(update_df, check_existing=False, debug=debug)
2198        if not temp_success:
2199            return temp_success, temp_msg
2200
2201        existing_cols = pipe.get_columns_types(debug=debug)
2202        ### A partitioned table (TimescaleDB hypertable or a native range-partitioned table on
2203        ### PostgreSQL/MySQL/MSSQL) folds the datetime column into its composite primary key, so the
2204        ### upsert conflict target must include it too — `ON CONFLICT (primary_key)` alone has no
2205        ### matching unique constraint. Non-partitioned tables keep the historical primary-key-only
2206        ### semantics ("the primary key is the identity, regardless of datetime").
2207        partition_upsert = bool(
2208            dt_col
2209            and dt_col in update_df.columns
2210            and (
2211                self.flavor in ('timescaledb', 'timescaledb-ha')
2212                or self._should_partition(pipe)
2213            )
2214        )
2215        join_cols = [
2216            col
2217            for col_key, col in pipe.columns.items()
2218            if col and col in existing_cols
2219        ] if not primary_key or self.flavor == 'oracle' else (
2220            [dt_col, primary_key]
2221            if partition_upsert
2222            else [primary_key]
2223        )
2224        update_queries = get_update_queries(
2225            pipe.target,
2226            temp_target,
2227            self,
2228            join_cols,
2229            upsert=upsert,
2230            schema=self.get_pipe_schema(pipe),
2231            patch_schema=self.internal_schema,
2232            target_cols_types=pipe.get_columns_types(debug=debug),
2233            patch_cols_types=_temp_columns_types,
2234            datetime_col=(dt_col if dt_col in update_df.columns else None),
2235            identity_insert=(autoincrement and primary_key in update_df.columns),
2236            null_indices=pipe.null_indices,
2237            cast_columns=pipe.enforce,
2238            debug=debug,
2239        )
2240        update_results = self.exec_queries(
2241            update_queries,
2242            break_on_error=True,
2243            rollback=True,
2244            debug=debug,
2245        )
2246        update_success = all(update_results)
2247        self._log_temporary_tables_creation(
2248            temp_target,
2249            ready_to_drop=True,
2250            create=(not pipe.temporary),
2251            debug=debug,
2252        )
2253        if not update_success:
2254            warn(f"Failed to apply update to {pipe}.")
2255        stats['success'] = stats['success'] and update_success
2256        stats['msg'] = (
2257            (stats.get('msg', '') + f'\nFailed to apply update to {pipe}.').lstrip()
2258            if not update_success
2259            else stats.get('msg', '')
2260        )
2261
2262    stop = time.perf_counter()
2263    success = stats['success']
2264    if not success:
2265        return success, stats['msg'] or str(stats)
2266
2267    unseen_count = len(unseen_df.index) if unseen_df is not None else 0
2268    update_count = len(update_df.index) if update_df is not None else 0
2269    msg = (
2270        (
2271            f"Inserted {unseen_count:,}, "
2272            + f"updated {update_count:,} rows."
2273        )
2274        if not upsert
2275        else (
2276            f"Upserted {update_count:,} row"
2277            + ('s' if update_count != 1 else '')
2278            + "."
2279        )
2280    )
2281    if debug:
2282        msg = msg[:-1] + (
2283            f"\non table {sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))}\n"
2284            + f"in {round(stop - start, 2)} seconds."
2285        )
2286
2287    if _check_temporary_tables:
2288        drop_stale_success, drop_stale_msg = self._drop_old_temporary_tables(
2289            refresh=False, debug=debug
2290        )
2291        if not drop_stale_success:
2292            warn(drop_stale_msg)
2293
2294    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]:
2297def sync_pipe_inplace(
2298    self,
2299    pipe: 'mrsm.Pipe',
2300    params: Optional[Dict[str, Any]] = None,
2301    begin: Union[datetime, int, None] = None,
2302    end: Union[datetime, int, None] = None,
2303    chunksize: Optional[int] = -1,
2304    check_existing: bool = True,
2305    debug: bool = False,
2306    **kw: Any
2307) -> SuccessTuple:
2308    """
2309    If a pipe's connector is the same as its instance connector,
2310    it's more efficient to sync the pipe in-place rather than reading data into Pandas.
2311
2312    Parameters
2313    ----------
2314    pipe: mrsm.Pipe
2315        The pipe whose connector is the same as its instance.
2316
2317    params: Optional[Dict[str, Any]], default None
2318        Optional params dictionary to build the `WHERE` clause.
2319        See `meerschaum.utils.sql.build_where`.
2320
2321    begin: Union[datetime, int, None], default None
2322        Optionally specify the earliest datetime to search for data.
2323        Defaults to `None`.
2324
2325    end: Union[datetime, int, None], default None
2326        Optionally specify the latest datetime to search for data.
2327        Defaults to `None`.
2328
2329    chunksize: Optional[int], default -1
2330        Specify the number of rows to sync per chunk.
2331        If `-1`, resort to system configuration (default is `900`).
2332        A `chunksize` of `None` will sync all rows in one transaction.
2333        Defaults to `-1`.
2334
2335    check_existing: bool, default True
2336        If `True`, pull and diff with existing data from the pipe.
2337
2338    debug: bool, default False
2339        Verbosity toggle.
2340
2341    Returns
2342    -------
2343    A SuccessTuple.
2344    """
2345    if self.flavor == 'duckdb':
2346        return pipe.sync(
2347            params=params,
2348            begin=begin,
2349            end=end,
2350            chunksize=chunksize,
2351            check_existing=check_existing,
2352            debug=debug,
2353            _inplace=False,
2354            **kw
2355        )
2356    from meerschaum.utils.sql import (
2357        sql_item_name,
2358        get_update_queries,
2359        get_null_replacement,
2360        get_create_table_queries,
2361        get_create_schema_if_not_exists_queries,
2362        get_table_cols_types,
2363        session_execute,
2364        dateadd_str,
2365        UPDATE_QUERIES,
2366    )
2367    from meerschaum.utils.dtypes.sql import (
2368        get_pd_type_from_db_type,
2369        get_db_type_from_pd_type,
2370    )
2371    from meerschaum.utils.misc import generate_password
2372
2373    transaction_id_length = (
2374        mrsm.get_config(
2375            'system', 'connectors', 'sql', 'instance', 'temporary_target', 'transaction_id_length'
2376        )
2377    )
2378    transact_id = generate_password(transaction_id_length)
2379
2380    internal_schema = self.internal_schema
2381    target = pipe.target
2382    temp_table_roots = ['backtrack', 'new', 'delta', 'joined', 'unseen', 'update']
2383    temp_tables = {
2384        table_root: self.get_temporary_target(target, transact_id=transact_id, label=table_root)
2385        for table_root in temp_table_roots
2386    }
2387    temp_table_names = {
2388        table_root: sql_item_name(table_name_raw, self.flavor, internal_schema)
2389        for table_root, table_name_raw in temp_tables.items()
2390    }
2391    temp_table_aliases = {
2392        table_root: sql_item_name(table_root, self.flavor)
2393        for table_root in temp_table_roots
2394    }
2395    table_alias_as = " AS" if self.flavor != 'oracle' else ''
2396    metadef = self.get_pipe_metadef(
2397        pipe,
2398        params=params,
2399        begin=begin,
2400        end=end,
2401        check_existing=check_existing,
2402        debug=debug,
2403    )
2404    pipe_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
2405    upsert = pipe.parameters.get('upsert', False) and f'{self.flavor}-upsert' in UPDATE_QUERIES
2406    static = pipe.parameters.get('static', False)
2407    database = getattr(self, 'database', self.parse_uri(self.URI).get('database', None))
2408    primary_key = pipe.columns.get('primary', None)
2409    primary_key_typ = pipe.dtypes.get(primary_key, None) if primary_key else None
2410    primary_key_db_type = (
2411        get_db_type_from_pd_type(primary_key_typ, self.flavor)
2412        if primary_key_typ
2413        else None
2414    )
2415    if not {col_key: col for col_key, col in pipe.columns.items() if col_key and col}:
2416        return False, "Cannot sync in-place without index columns."
2417
2418    autoincrement = pipe.parameters.get('autoincrement', False)
2419    dt_col = pipe.columns.get('datetime', None)
2420    dt_col_name = sql_item_name(dt_col, self.flavor, None) if dt_col else None
2421    dt_typ = pipe.dtypes.get(dt_col, 'datetime') if dt_col else None
2422    dt_db_type = get_db_type_from_pd_type(dt_typ, self.flavor) if dt_typ else None
2423
2424    def clean_up_temp_tables(ready_to_drop: bool = False):
2425        log_success, log_msg = self._log_temporary_tables_creation(
2426            [
2427                table
2428                for table in temp_tables.values()
2429            ] if not upsert else [temp_tables['update']],
2430            ready_to_drop=ready_to_drop,
2431            create=(not pipe.temporary),
2432            debug=debug,
2433        )
2434        if not log_success:
2435            warn(log_msg)
2436        drop_stale_success, drop_stale_msg = self._drop_old_temporary_tables(
2437            refresh=False,
2438            debug=debug,
2439        )
2440        if not drop_stale_success:
2441            warn(drop_stale_msg)
2442        return drop_stale_success, drop_stale_msg
2443
2444    sqlalchemy, sqlalchemy_orm = mrsm.attempt_import(
2445        'sqlalchemy',
2446        'sqlalchemy.orm',
2447    )
2448    if not pipe.exists(debug=debug):
2449        schema = self.get_pipe_schema(pipe)
2450        create_pipe_queries = get_create_table_queries(
2451            metadef,
2452            pipe.target,
2453            self.flavor,
2454            schema=schema,
2455            primary_key=primary_key,
2456            primary_key_db_type=primary_key_db_type,
2457            autoincrement=autoincrement,
2458            datetime_column=dt_col,
2459        )
2460        if schema:
2461            create_pipe_queries = (
2462                get_create_schema_if_not_exists_queries(schema, self.flavor)
2463                + create_pipe_queries
2464            )
2465
2466        results = self.exec_queries(create_pipe_queries, debug=debug)
2467        if not all(results):
2468            _ = clean_up_temp_tables()
2469            return False, f"Could not insert new data into {pipe} from its SQL query definition."
2470
2471        if not self.create_indices(pipe, debug=debug):
2472            warn(f"Failed to create indices for {pipe}. Continuing...")
2473
2474        rowcount = pipe.get_rowcount(debug=debug)
2475        _ = clean_up_temp_tables()
2476        return True, f"Inserted {rowcount:,}, updated 0 rows."
2477
2478    session = sqlalchemy_orm.Session(self.engine)
2479    connectable = session if self.flavor != 'duckdb' else self
2480
2481    create_new_query = get_create_table_queries(
2482        metadef,
2483        temp_tables[('new') if not upsert else 'update'],
2484        self.flavor,
2485        schema=internal_schema,
2486    )[0]
2487    (create_new_success, create_new_msg), create_new_results = session_execute(
2488        session,
2489        create_new_query,
2490        with_results=True,
2491        debug=debug,
2492    )
2493    if not create_new_success:
2494        _ = clean_up_temp_tables()
2495        return create_new_success, create_new_msg
2496    new_count = create_new_results[0].rowcount if create_new_results else 0
2497
2498    new_cols_types = get_table_cols_types(
2499        temp_tables[('new' if not upsert else 'update')],
2500        connectable=connectable,
2501        flavor=self.flavor,
2502        schema=internal_schema,
2503        database=database,
2504        debug=debug,
2505    ) if not static else pipe.get_columns_types(debug=debug)
2506    if not new_cols_types:
2507        return False, f"Failed to get new columns for {pipe}."
2508
2509    new_cols = {
2510        str(col_name): get_pd_type_from_db_type(str(col_type))
2511        for col_name, col_type in new_cols_types.items()
2512    }
2513    new_cols_str = '\n    ' + ',\n    '.join([
2514        sql_item_name(col, self.flavor)
2515        for col in new_cols
2516    ])
2517    def get_col_typ(col: str, cols_types: Dict[str, str]) -> str:
2518        if self.flavor == 'oracle' and new_cols_types.get(col, '').lower() == 'char':
2519            return new_cols_types[col]
2520        return cols_types[col]
2521
2522    add_cols_queries = self.get_add_columns_queries(pipe, new_cols, debug=debug)
2523    if add_cols_queries:
2524        pipe._clear_cache_key('_columns_types', debug=debug)
2525        pipe._clear_cache_key('_columns_indices', debug=debug)
2526        self.exec_queries(add_cols_queries, debug=debug)
2527
2528    alter_cols_queries = self.get_alter_columns_queries(pipe, new_cols, debug=debug)
2529    if alter_cols_queries:
2530        pipe._clear_cache_key('_columns_types', debug=debug)
2531        self.exec_queries(alter_cols_queries, debug=debug)
2532
2533    insert_queries = [
2534        (
2535            f"INSERT INTO {pipe_name} ({new_cols_str})\n"
2536            f"SELECT {new_cols_str}\nFROM {temp_table_names['new']}{table_alias_as}"
2537            f" {temp_table_aliases['new']}"
2538        )
2539    ] if not check_existing and not upsert else []
2540
2541    new_queries = insert_queries
2542    new_success, new_msg = (
2543        session_execute(session, new_queries, debug=debug)
2544        if new_queries
2545        else (True, "Success")
2546    )
2547    if not new_success:
2548        _ = clean_up_temp_tables()
2549        return new_success, new_msg
2550
2551    if not check_existing:
2552        session.commit()
2553        _ = clean_up_temp_tables()
2554        return True, f"Inserted {new_count}, updated 0 rows."
2555
2556    min_dt_col_name_da = dateadd_str(
2557        flavor=self.flavor, begin=f"MIN({dt_col_name})", db_type=dt_db_type,
2558    )
2559    max_dt_col_name_da = dateadd_str(
2560        flavor=self.flavor, begin=f"MAX({dt_col_name})", db_type=dt_db_type,
2561    )
2562
2563    (new_dt_bounds_success, new_dt_bounds_msg), new_dt_bounds_results = session_execute(
2564        session,
2565        [
2566            "SELECT\n"
2567            f"    {min_dt_col_name_da} AS {sql_item_name('min_dt', self.flavor)},\n"
2568            f"    {max_dt_col_name_da} AS {sql_item_name('max_dt', self.flavor)}\n"
2569            f"FROM {temp_table_names['new' if not upsert else 'update']}\n"
2570            f"WHERE {dt_col_name} IS NOT NULL"
2571        ],
2572        with_results=True,
2573        debug=debug,
2574    ) if dt_col and not upsert else ((True, "Success"), None)
2575    if not new_dt_bounds_success:
2576        return (
2577            new_dt_bounds_success,
2578            f"Could not determine in-place datetime bounds:\n{new_dt_bounds_msg}"
2579        )
2580
2581    if dt_col and not upsert:
2582        begin, end = new_dt_bounds_results[0].fetchone()
2583
2584    backtrack_def = self.get_pipe_data_query(
2585        pipe,
2586        begin=begin,
2587        end=end,
2588        begin_add_minutes=0,
2589        end_add_minutes=1,
2590        params=params,
2591        debug=debug,
2592        order=None,
2593    )
2594    create_backtrack_query = get_create_table_queries(
2595        backtrack_def,
2596        temp_tables['backtrack'],
2597        self.flavor,
2598        schema=internal_schema,
2599    )[0]
2600    (create_backtrack_success, create_backtrack_msg), create_backtrack_results = session_execute(
2601        session,
2602        create_backtrack_query,
2603        with_results=True,
2604        debug=debug,
2605    ) if not upsert else ((True, "Success"), None)
2606
2607    if not create_backtrack_success:
2608        _ = clean_up_temp_tables()
2609        return create_backtrack_success, create_backtrack_msg
2610
2611    backtrack_cols_types = get_table_cols_types(
2612        temp_tables['backtrack'],
2613        connectable=connectable,
2614        flavor=self.flavor,
2615        schema=internal_schema,
2616        database=database,
2617        debug=debug,
2618    ) if not (upsert or static) else new_cols_types
2619
2620    common_cols = [col for col in new_cols if col in backtrack_cols_types]
2621    primary_key = pipe.columns.get('primary', None)
2622    on_cols = {
2623        col: new_cols.get(col)
2624        for col_key, col in pipe.columns.items()
2625        if (
2626            col
2627            and
2628            col_key != 'value'
2629            and col in backtrack_cols_types
2630            and col in new_cols
2631        )
2632    } if not primary_key else {primary_key: new_cols.get(primary_key)}
2633    if not on_cols:
2634        raise ValueError("Cannot sync without common index columns.")
2635
2636    null_replace_new_cols_str = (
2637        '\n    ' + ',\n    '.join([
2638            f"COALESCE({temp_table_aliases['new']}.{sql_item_name(col, self.flavor)}, "
2639            + get_null_replacement(get_col_typ(col, new_cols_types), self.flavor)
2640            + ") AS "
2641            + sql_item_name(col, self.flavor, None)
2642            for col, typ in new_cols.items()
2643        ])
2644    )
2645
2646    select_delta_query = (
2647        "SELECT"
2648        + null_replace_new_cols_str
2649        + f"\nFROM {temp_table_names['new']}{table_alias_as} {temp_table_aliases['new']}\n"
2650        + f"LEFT OUTER JOIN {temp_table_names['backtrack']}{table_alias_as} {temp_table_aliases['backtrack']}"
2651        + "\n    ON\n    "
2652        + '\n    AND\n    '.join([
2653            (
2654                f"    COALESCE({temp_table_aliases['new']}."
2655                + sql_item_name(c, self.flavor, None)
2656                + ", "
2657                + get_null_replacement(get_col_typ(c, new_cols_types), self.flavor)
2658                + ")"
2659                + '\n        =\n    '
2660                + f"    COALESCE({temp_table_aliases['backtrack']}."
2661                + sql_item_name(c, self.flavor, None)
2662                + ", "
2663                + get_null_replacement(get_col_typ(c, backtrack_cols_types), self.flavor)
2664                + ") "
2665            ) for c in common_cols
2666        ])
2667        + "\nWHERE\n    "
2668        + '\n    AND\n    '.join([
2669            (
2670                f"{temp_table_aliases['backtrack']}." + sql_item_name(c, self.flavor) + ' IS NULL'
2671            ) for c in common_cols
2672        ])
2673    )
2674    create_delta_query = get_create_table_queries(
2675        select_delta_query,
2676        temp_tables['delta'],
2677        self.flavor,
2678        schema=internal_schema,
2679    )[0]
2680    create_delta_success, create_delta_msg = session_execute(
2681        session,
2682        create_delta_query,
2683        debug=debug,
2684    ) if not upsert else (True, "Success")
2685    if not create_delta_success:
2686        _ = clean_up_temp_tables()
2687        return create_delta_success, create_delta_msg
2688
2689    delta_cols_types = get_table_cols_types(
2690        temp_tables['delta'],
2691        connectable=connectable,
2692        flavor=self.flavor,
2693        schema=internal_schema,
2694        database=database,
2695        debug=debug,
2696    ) if not (upsert or static) else new_cols_types
2697
2698    ### This is a weird bug on SQLite.
2699    ### Sometimes the backtrack dtypes are all empty strings.
2700    if not all(delta_cols_types.values()):
2701        delta_cols_types = new_cols_types
2702
2703    delta_cols = {
2704        col: get_pd_type_from_db_type(typ)
2705        for col, typ in delta_cols_types.items()
2706    }
2707    delta_cols_str = ', '.join([
2708        sql_item_name(col, self.flavor)
2709        for col in delta_cols
2710    ])
2711
2712    select_joined_query = (
2713        "SELECT\n    "
2714        + (',\n    '.join([
2715            (
2716                f"{temp_table_aliases['delta']}." + sql_item_name(c, self.flavor, None)
2717                + " AS " + sql_item_name(c + '_delta', self.flavor, None)
2718            ) for c in delta_cols
2719        ]))
2720        + ",\n    "
2721        + (',\n    '.join([
2722            (
2723                f"{temp_table_aliases['backtrack']}." + sql_item_name(c, self.flavor, None)
2724                + " AS " + sql_item_name(c + '_backtrack', self.flavor, None)
2725            ) for c in backtrack_cols_types
2726        ]))
2727        + f"\nFROM {temp_table_names['delta']}{table_alias_as} {temp_table_aliases['delta']}\n"
2728        + f"LEFT OUTER JOIN {temp_table_names['backtrack']}{table_alias_as}"
2729        + f" {temp_table_aliases['backtrack']}"
2730        + "\n    ON\n    "
2731        + '\n    AND\n    '.join([
2732            (
2733                f"    COALESCE({temp_table_aliases['delta']}." + sql_item_name(c, self.flavor)
2734                + ", "
2735                + get_null_replacement(get_col_typ(c, new_cols_types), self.flavor) + ")"
2736                + '\n        =\n    '
2737                + f"    COALESCE({temp_table_aliases['backtrack']}." + sql_item_name(c, self.flavor)
2738                + ", "
2739                + get_null_replacement(get_col_typ(c, new_cols_types), self.flavor) + ")"
2740            ) for c, typ in on_cols.items()
2741        ])
2742    )
2743
2744    create_joined_query = get_create_table_queries(
2745        select_joined_query,
2746        temp_tables['joined'],
2747        self.flavor,
2748        schema=internal_schema,
2749    )[0]
2750    create_joined_success, create_joined_msg = session_execute(
2751        session,
2752        create_joined_query,
2753        debug=debug,
2754    ) if on_cols and not upsert else (True, "Success")
2755    if not create_joined_success:
2756        _ = clean_up_temp_tables()
2757        return create_joined_success, create_joined_msg
2758
2759    select_unseen_query = (
2760        "SELECT\n    "
2761        + (',\n    '.join([
2762            (
2763                "CASE\n        WHEN " + sql_item_name(c + '_delta', self.flavor, None)
2764                + " != " + get_null_replacement(get_col_typ(c, delta_cols_types), self.flavor)
2765                + " THEN " + sql_item_name(c + '_delta', self.flavor, None)
2766                + "\n        ELSE NULL\n    END"
2767                + " AS " + sql_item_name(c, self.flavor, None)
2768            ) for c, typ in delta_cols.items()
2769        ]))
2770        + f"\nFROM {temp_table_names['joined']}{table_alias_as} {temp_table_aliases['joined']}\n"
2771        + "WHERE\n    "
2772        + '\n    AND\n    '.join([
2773            (
2774                sql_item_name(c + '_backtrack', self.flavor, None) + ' IS NULL'
2775            ) for c in delta_cols
2776        ])
2777    )
2778    create_unseen_query = get_create_table_queries(
2779        select_unseen_query,
2780        temp_tables['unseen'],
2781        self.flavor,
2782        internal_schema,
2783    )[0]
2784    (create_unseen_success, create_unseen_msg), create_unseen_results = session_execute(
2785        session,
2786        create_unseen_query,
2787        with_results=True,
2788        debug=debug
2789    ) if not upsert else ((True, "Success"), None)
2790    if not create_unseen_success:
2791        _ = clean_up_temp_tables()
2792        return create_unseen_success, create_unseen_msg
2793
2794    select_update_query = (
2795        "SELECT\n    "
2796        + (',\n    '.join([
2797            (
2798                "CASE\n        WHEN " + sql_item_name(c + '_delta', self.flavor, None)
2799                + " != " + get_null_replacement(get_col_typ(c, delta_cols_types), self.flavor)
2800                + " THEN " + sql_item_name(c + '_delta', self.flavor, None)
2801                + "\n        ELSE NULL\n    END"
2802                + " AS " + sql_item_name(c, self.flavor, None)
2803            ) for c, typ in delta_cols.items()
2804        ]))
2805        + f"\nFROM {temp_table_names['joined']}{table_alias_as} {temp_table_aliases['joined']}\n"
2806        + "WHERE\n    "
2807        + '\n    OR\n    '.join([
2808            (
2809                sql_item_name(c + '_backtrack', self.flavor, None) + ' IS NOT NULL'
2810            ) for c in delta_cols
2811        ])
2812    )
2813
2814    create_update_query = get_create_table_queries(
2815        select_update_query,
2816        temp_tables['update'],
2817        self.flavor,
2818        internal_schema,
2819    )[0]
2820    (create_update_success, create_update_msg), create_update_results = session_execute(
2821        session,
2822        create_update_query,
2823        with_results=True,
2824        debug=debug,
2825    ) if on_cols and not upsert else ((True, "Success"), [])
2826    apply_update_queries = (
2827        get_update_queries(
2828            pipe.target,
2829            temp_tables['update'],
2830            session,
2831            on_cols,
2832            upsert=upsert,
2833            schema=self.get_pipe_schema(pipe),
2834            patch_schema=internal_schema,
2835            target_cols_types=pipe.get_columns_types(debug=debug),
2836            patch_cols_types=delta_cols_types,
2837            datetime_col=pipe.columns.get('datetime', None),
2838            flavor=self.flavor,
2839            null_indices=pipe.null_indices,
2840            cast_columns=pipe.enforce,
2841            debug=debug,
2842        )
2843        if on_cols else []
2844    )
2845
2846    apply_unseen_queries = [
2847        (
2848            f"INSERT INTO {pipe_name} ({delta_cols_str})\n"
2849            + f"SELECT {delta_cols_str}\nFROM "
2850            + (
2851                temp_table_names['unseen']
2852                if on_cols
2853                else temp_table_names['delta']
2854            )
2855        ),
2856    ]
2857
2858    (apply_unseen_success, apply_unseen_msg), apply_unseen_results = session_execute(
2859        session,
2860        apply_unseen_queries,
2861        with_results=True,
2862        debug=debug,
2863    ) if not upsert else ((True, "Success"), None)
2864    if not apply_unseen_success:
2865        _ = clean_up_temp_tables()
2866        return apply_unseen_success, apply_unseen_msg
2867    unseen_count = apply_unseen_results[0].rowcount if apply_unseen_results else 0
2868
2869    (apply_update_success, apply_update_msg), apply_update_results = session_execute(
2870        session,
2871        apply_update_queries,
2872        with_results=True,
2873        debug=debug,
2874    )
2875    if not apply_update_success:
2876        _ = clean_up_temp_tables()
2877        return apply_update_success, apply_update_msg
2878    update_count = apply_update_results[0].rowcount if apply_update_results else 0
2879
2880    session.commit()
2881
2882    msg = (
2883        f"Inserted {unseen_count:,}, updated {update_count:,} rows."
2884        if not upsert
2885        else f"Upserted {update_count:,} row" + ('s' if update_count != 1 else '') + "."
2886    )
2887    _ = clean_up_temp_tables(ready_to_drop=True)
2888
2889    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]:
2892def get_sync_time(
2893    self,
2894    pipe: 'mrsm.Pipe',
2895    params: Optional[Dict[str, Any]] = None,
2896    newest: bool = True,
2897    remote: bool = False,
2898    debug: bool = False,
2899) -> Union[datetime, int, None]:
2900    """Get a Pipe's most recent datetime value.
2901
2902    Parameters
2903    ----------
2904    pipe: mrsm.Pipe
2905        The pipe to get the sync time for.
2906
2907    params: Optional[Dict[str, Any]], default None
2908        Optional params dictionary to build the `WHERE` clause.
2909        See `meerschaum.utils.sql.build_where`.
2910
2911    newest: bool, default True
2912        If `True`, get the most recent datetime (honoring `params`).
2913        If `False`, get the oldest datetime (ASC instead of DESC).
2914
2915    remote: bool, default False
2916        If `True`, return the sync time for the remote fetch definition.
2917
2918    Returns
2919    -------
2920    A `datetime` object (or `int` if using an integer axis) if the pipe exists, otherwise `None`.
2921    """
2922    from meerschaum.utils.sql import sql_item_name, build_where, wrap_query_with_cte
2923    src_name = sql_item_name('src', self.flavor)
2924    table_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
2925
2926    dt_col = pipe.columns.get('datetime', None)
2927    if dt_col is None:
2928        return None
2929    dt_col_name = sql_item_name(dt_col, self.flavor, None)
2930
2931    if remote and pipe.connector.type != 'sql':
2932        warn(f"Cannot get the remote sync time for {pipe}.")
2933        return None
2934
2935    ASC_or_DESC = "DESC" if newest else "ASC"
2936    existing_cols = pipe.get_columns_types(debug=debug)
2937    if not remote and not existing_cols:
2938        return None
2939    valid_params = {}
2940    if params is not None:
2941        valid_params = {k: v for k, v in params.items() if k in existing_cols}
2942    flavor = self.flavor if not remote else pipe.connector.flavor
2943
2944    ### If no bounds are provided for the datetime column,
2945    ### add IS NOT NULL to the WHERE clause.
2946    if dt_col not in valid_params:
2947        valid_params[dt_col] = '_None'
2948    where = "" if not valid_params else build_where(valid_params, self)
2949    src_query = (
2950        f"SELECT {dt_col_name}\nFROM {table_name}{where}"
2951        if not remote
2952        else self.get_pipe_metadef(pipe, params=params, begin=None, end=None)
2953    )
2954
2955    base_query = (
2956        f"SELECT {dt_col_name}\n"
2957        f"FROM {src_name}\n"
2958        f"ORDER BY {dt_col_name} {ASC_or_DESC}\n"
2959        f"LIMIT 1"
2960    )
2961    if self.flavor == 'mssql':
2962        base_query = (
2963            f"SELECT TOP 1 {dt_col_name}\n"
2964            f"FROM {src_name}\n"
2965            f"ORDER BY {dt_col_name} {ASC_or_DESC}"
2966        )
2967    elif self.flavor == 'oracle':
2968        base_query = (
2969            "SELECT * FROM (\n"
2970            f"    SELECT {dt_col_name}\n"
2971            f"    FROM {src_name}\n"
2972            f"    ORDER BY {dt_col_name} {ASC_or_DESC}\n"
2973            ") WHERE ROWNUM = 1"
2974        )
2975
2976    ### NOTE: MariaDB has an optimizer bug where `ORDER BY <dt> DESC/ASC LIMIT 1` against a
2977    ### `RANGE COLUMNS` partitioned table combined with a `WHERE` clause performs a partition
2978    ### index scan that stops early and returns zero rows (observed on MariaDB 12.x). The
2979    ### equivalent `MIN`/`MAX` aggregate scans the pruned partitions correctly, so use it for
2980    ### the bounds on partitioned MariaDB tables instead.
2981    if self.flavor == 'mariadb' and not remote and self._should_partition(pipe):
2982        agg_func = "MAX" if newest else "MIN"
2983        base_query = (
2984            f"SELECT {agg_func}({dt_col_name}) AS {dt_col_name}\n"
2985            f"FROM {src_name}"
2986        )
2987
2988    query = wrap_query_with_cte(src_query, base_query, flavor)
2989
2990    try:
2991        db_time = self.value(query, silent=True, debug=debug)
2992
2993        ### No datetime could be found.
2994        if db_time is None:
2995            return None
2996        ### sqlite returns str.
2997        if isinstance(db_time, str):
2998            dateutil_parser = mrsm.attempt_import('dateutil.parser')
2999            st = dateutil_parser.parse(db_time)
3000        ### Do nothing if a datetime object is returned.
3001        elif isinstance(db_time, datetime):
3002            if hasattr(db_time, 'to_pydatetime'):
3003                st = db_time.to_pydatetime()
3004            else:
3005                st = db_time
3006        ### Sometimes the datetime is actually a date.
3007        elif isinstance(db_time, date):
3008            st = datetime.combine(db_time, datetime.min.time())
3009        ### Adding support for an integer datetime axis.
3010        elif 'int' in str(type(db_time)).lower():
3011            st = int(db_time)
3012        ### Convert pandas timestamp to Python datetime.
3013        else:
3014            st = db_time.to_pydatetime()
3015
3016        sync_time = st
3017
3018    except Exception as e:
3019        sync_time = None
3020        warn(str(e))
3021
3022    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:
3025def pipe_exists(
3026    self,
3027    pipe: mrsm.Pipe,
3028    debug: bool = False
3029) -> bool:
3030    """
3031    Check that a Pipe's table exists.
3032
3033    Parameters
3034    ----------
3035    pipe: mrsm.Pipe:
3036        The pipe to check.
3037
3038    debug: bool, default False
3039        Verbosity toggle.
3040
3041    Returns
3042    -------
3043    A `bool` corresponding to whether a pipe's table exists.
3044
3045    """
3046    from meerschaum.utils.sql import table_exists
3047    exists = table_exists(
3048        pipe.target,
3049        self,
3050        schema=self.get_pipe_schema(pipe),
3051        debug=debug,
3052    )
3053    if debug:
3054        dprint(f"{pipe} " + ('exists.' if exists else 'does not exist.'))
3055    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]:
3058def get_pipe_rowcount(
3059    self,
3060    pipe: mrsm.Pipe,
3061    begin: Union[datetime, int, None] = None,
3062    end: Union[datetime, int, None] = None,
3063    params: Optional[Dict[str, Any]] = None,
3064    remote: bool = False,
3065    debug: bool = False
3066) -> Union[int, None]:
3067    """
3068    Get the rowcount for a pipe in accordance with given parameters.
3069
3070    Parameters
3071    ----------
3072    pipe: mrsm.Pipe
3073        The pipe to query with.
3074
3075    begin: Union[datetime, int, None], default None
3076        The begin datetime value.
3077
3078    end: Union[datetime, int, None], default None
3079        The end datetime value.
3080
3081    params: Optional[Dict[str, Any]], default None
3082        See `meerschaum.utils.sql.build_where`.
3083
3084    remote: bool, default False
3085        If `True`, get the rowcount for the remote table.
3086
3087    debug: bool, default False
3088        Verbosity toggle.
3089
3090    Returns
3091    -------
3092    An `int` for the number of rows if the `pipe` exists, otherwise `None`.
3093
3094    """
3095    from meerschaum.utils.sql import dateadd_str, sql_item_name, wrap_query_with_cte, build_where
3096    from meerschaum.connectors.sql._fetch import get_pipe_query
3097    from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type
3098    if remote:
3099        msg = f"'fetch:definition' must be an attribute of {pipe} to get a remote rowcount."
3100        if 'fetch' not in pipe.parameters:
3101            error(msg)
3102            return None
3103        if 'definition' not in pipe.parameters['fetch']:
3104            error(msg)
3105            return None
3106    elif not pipe.exists(debug=debug):
3107        return None
3108
3109    flavor = self.flavor if not remote else pipe.connector.flavor
3110    conn = self if not remote else pipe.connector
3111    _pipe_name = sql_item_name(pipe.target, flavor, self.get_pipe_schema(pipe))
3112    dt_col = pipe.columns.get('datetime', None)
3113    dt_typ = pipe.dtypes.get(dt_col, 'datetime') if dt_col else None
3114    dt_db_type = get_db_type_from_pd_type(dt_typ, flavor) if dt_typ else None
3115    if not dt_col:
3116        dt_col = pipe.guess_datetime()
3117        dt_name = sql_item_name(dt_col, flavor, None) if dt_col else None
3118        is_guess = True
3119    else:
3120        dt_col = pipe.get_columns('datetime')
3121        dt_name = sql_item_name(dt_col, flavor, None)
3122        is_guess = False
3123
3124    if begin is not None or end is not None:
3125        if is_guess:
3126            if dt_col is None:
3127                warn(
3128                    f"No datetime could be determined for {pipe}."
3129                    + "\n    Ignoring begin and end...",
3130                    stack=False,
3131                )
3132                begin, end = None, None
3133            else:
3134                warn(
3135                    f"A datetime wasn't specified for {pipe}.\n"
3136                    + f"    Using column \"{dt_col}\" for datetime bounds...",
3137                    stack=False,
3138                )
3139
3140
3141    _datetime_name = sql_item_name(dt_col, flavor)
3142    _cols_names = [
3143        sql_item_name(col, flavor)
3144        for col in set(
3145            (
3146                [dt_col]
3147                if dt_col
3148                else []
3149            ) + (
3150                []
3151                if params is None
3152                else list(params.keys())
3153            )
3154        )
3155    ]
3156    if not _cols_names:
3157        _cols_names = ['*']
3158
3159    src = (
3160        f"SELECT {', '.join(_cols_names)}\nFROM {_pipe_name}"
3161        if not remote
3162        else get_pipe_query(pipe)
3163    )
3164    parent_query = f"SELECT COUNT(*)\nFROM {sql_item_name('src', flavor)}"
3165    query = wrap_query_with_cte(src, parent_query, flavor)
3166    if begin is not None or end is not None:
3167        query += "\nWHERE"
3168    if begin is not None:
3169        query += (
3170            f"\n    {dt_name} >= "
3171            + dateadd_str(flavor, datepart='minute', number=0, begin=begin, db_type=dt_db_type)
3172        )
3173    if end is not None and begin is not None:
3174        query += "\n    AND"
3175    if end is not None:
3176        query += (
3177            f"\n    {dt_name} <  "
3178            + dateadd_str(flavor, datepart='minute', number=0, begin=end, db_type=dt_db_type)
3179        )
3180    if params is not None:
3181        existing_cols = pipe.get_columns_types(debug=debug)
3182        valid_params = {k: v for k, v in params.items() if k in existing_cols}
3183        if valid_params:
3184            query += build_where(valid_params, conn).replace('WHERE', (
3185                'AND' if (begin is not None or end is not None)
3186                    else 'WHERE'
3187                )
3188            )
3189
3190    result = conn.value(query, debug=debug, silent=True)
3191    try:
3192        return int(result)
3193    except Exception:
3194        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]:
3197def drop_pipe(
3198    self,
3199    pipe: mrsm.Pipe,
3200    debug: bool = False,
3201    **kw
3202) -> SuccessTuple:
3203    """
3204    Drop a pipe's tables but maintain its registration.
3205
3206    Parameters
3207    ----------
3208    pipe: mrsm.Pipe
3209        The pipe to drop.
3210
3211    Returns
3212    -------
3213    A `SuccessTuple` indicated success.
3214    """
3215    from meerschaum.utils.sql import table_exists, sql_item_name, DROP_IF_EXISTS_FLAVORS
3216    success = True
3217    target = pipe.target
3218    schema = self.get_pipe_schema(pipe)
3219    target_name = (
3220        sql_item_name(target, self.flavor, schema)
3221    )
3222    if table_exists(target, self, schema=schema, debug=debug):
3223        if_exists_str = "IF EXISTS" if self.flavor in DROP_IF_EXISTS_FLAVORS else ""
3224        success = self.exec(
3225            f"DROP TABLE {if_exists_str} {target_name}", silent=True, debug=debug
3226        ) is not None
3227
3228    ### Drop any MSSQL partition scheme + function the table referenced (no-op otherwise).
3229    if success:
3230        cleanup_queries = self._get_partition_cleanup_queries(pipe)
3231        if cleanup_queries:
3232            self.exec_queries(cleanup_queries, break_on_error=False, silent=True, debug=debug)
3233
3234    msg = "Success" if success else f"Failed to drop {pipe}."
3235    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]:
3238def clear_pipe(
3239    self,
3240    pipe: mrsm.Pipe,
3241    begin: Union[datetime, int, None] = None,
3242    end: Union[datetime, int, None] = None,
3243    params: Optional[Dict[str, Any]] = None,
3244    debug: bool = False,
3245    **kw
3246) -> SuccessTuple:
3247    """
3248    Delete a pipe's data within a bounded or unbounded interval without dropping the table.
3249
3250    Parameters
3251    ----------
3252    pipe: mrsm.Pipe
3253        The pipe to clear.
3254        
3255    begin: Union[datetime, int, None], default None
3256        Beginning datetime. Inclusive.
3257
3258    end: Union[datetime, int, None], default None
3259         Ending datetime. Exclusive.
3260
3261    params: Optional[Dict[str, Any]], default None
3262         See `meerschaum.utils.sql.build_where`.
3263
3264    """
3265    if not pipe.exists(debug=debug):
3266        return True, f"{pipe} does not exist, so nothing was cleared."
3267
3268    from meerschaum.utils.sql import sql_item_name, build_where, dateadd_str
3269    from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type
3270    pipe_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
3271
3272    dt_col = pipe.columns.get('datetime', None)
3273    dt_typ = pipe.dtypes.get(dt_col, 'datetime') if dt_col else None
3274    dt_db_type = get_db_type_from_pd_type(dt_typ, self.flavor) if dt_typ else None
3275    if not pipe.columns.get('datetime', None):
3276        dt_col = pipe.guess_datetime()
3277        dt_name = sql_item_name(dt_col, self.flavor, None) if dt_col else None
3278        is_guess = True
3279    else:
3280        dt_col = pipe.get_columns('datetime')
3281        dt_name = sql_item_name(dt_col, self.flavor, None)
3282        is_guess = False
3283
3284    if begin is not None or end is not None:
3285        if is_guess:
3286            if dt_col is None:
3287                warn(
3288                    f"No datetime could be determined for {pipe}."
3289                    + "\n    Ignoring datetime bounds...",
3290                    stack=False,
3291                )
3292                begin, end = None, None
3293            else:
3294                warn(
3295                    f"A datetime wasn't specified for {pipe}.\n"
3296                    + f"    Using column \"{dt_col}\" for datetime bounds...",
3297                    stack=False,
3298                )
3299
3300    valid_params = {}
3301    if params is not None:
3302        existing_cols = pipe.get_columns_types(debug=debug)
3303        valid_params = {k: v for k, v in params.items() if k in existing_cols}
3304    clear_query = (
3305        f"DELETE FROM {pipe_name}\nWHERE 1 = 1\n"
3306        + ('\n    AND ' + build_where(valid_params, self, with_where=False) if valid_params else '')
3307        + (
3308            (
3309                f'\n    AND {dt_name} >= '
3310                + dateadd_str(self.flavor, 'day', 0, begin, db_type=dt_db_type)
3311            )
3312            if begin is not None
3313            else ''
3314        ) + (
3315            (
3316                f'\n    AND {dt_name} <  '
3317                + dateadd_str(self.flavor, 'day', 0, end, db_type=dt_db_type)
3318            )
3319            if end is not None
3320            else ''
3321        )
3322    )
3323    success = self.exec(clear_query, silent=True, debug=debug) is not None
3324    msg = "Success" if success else f"Failed to clear {pipe}."
3325    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]:
3968def deduplicate_pipe(
3969    self,
3970    pipe: mrsm.Pipe,
3971    begin: Union[datetime, int, None] = None,
3972    end: Union[datetime, int, None] = None,
3973    params: Optional[Dict[str, Any]] = None,
3974    debug: bool = False,
3975    **kwargs: Any
3976) -> SuccessTuple:
3977    """
3978    Delete duplicate values within a pipe's table.
3979
3980    Parameters
3981    ----------
3982    pipe: mrsm.Pipe
3983        The pipe whose table to deduplicate.
3984
3985    begin: Union[datetime, int, None], default None
3986        If provided, only deduplicate values greater than or equal to this value.
3987
3988    end: Union[datetime, int, None], default None
3989        If provided, only deduplicate values less than this value.
3990
3991    params: Optional[Dict[str, Any]], default None
3992        If provided, further limit deduplication to values which match this query dictionary.
3993
3994    debug: bool, default False
3995        Verbosity toggle.
3996
3997    Returns
3998    -------
3999    A `SuccessTuple` indicating success.
4000    """
4001    from meerschaum.utils.sql import (
4002        sql_item_name,
4003        get_rename_table_queries,
4004        DROP_IF_EXISTS_FLAVORS,
4005        get_create_table_query,
4006        format_cte_subquery,
4007        get_null_replacement,
4008    )
4009    from meerschaum.utils.misc import generate_password, flatten_list
4010
4011    pipe_table_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))
4012
4013    if not pipe.exists(debug=debug):
4014        return False, f"Table {pipe_table_name} does not exist."
4015
4016    dt_col = pipe.columns.get('datetime', None)
4017    cols_types = pipe.get_columns_types(debug=debug)
4018    existing_cols = pipe.get_columns_types(debug=debug)
4019
4020    get_rowcount_query = f"SELECT COUNT(*) FROM {pipe_table_name}"
4021    old_rowcount = self.value(get_rowcount_query, debug=debug)
4022    if old_rowcount is None:
4023        return False, f"Failed to get rowcount for table {pipe_table_name}."
4024
4025    ### Non-datetime indices that in fact exist.
4026    indices = [
4027        col
4028        for key, col in pipe.columns.items()
4029        if col and col != dt_col and col in cols_types
4030    ]
4031    indices_names = [sql_item_name(index_col, self.flavor, None) for index_col in indices]
4032    existing_cols_names = [sql_item_name(col, self.flavor, None) for col in existing_cols]
4033    duplicate_row_number_name = sql_item_name('dup_row_num', self.flavor, None)
4034    previous_row_number_name = sql_item_name('prev_row_num', self.flavor, None)
4035
4036    index_list_str = (
4037        sql_item_name(dt_col, self.flavor, None)
4038        if dt_col
4039        else ''
4040    )
4041    index_list_str_ordered = (
4042        (
4043            sql_item_name(dt_col, self.flavor, None) + " DESC"
4044        )
4045        if dt_col
4046        else ''
4047    )
4048    if indices:
4049        index_list_str += ', ' + ', '.join(indices_names)
4050        index_list_str_ordered += ', ' + ', '.join(indices_names)
4051    if index_list_str.startswith(','):
4052        index_list_str = index_list_str.lstrip(',').lstrip()
4053    if index_list_str_ordered.startswith(','):
4054        index_list_str_ordered = index_list_str_ordered.lstrip(',').lstrip()
4055
4056    cols_list_str = ', '.join(existing_cols_names)
4057
4058    try:
4059        ### NOTE: MySQL 5 and below does not support window functions (ROW_NUMBER()).
4060        is_old_mysql = (
4061            self.flavor in ('mysql', 'mariadb')
4062            and
4063            int(self.db_version.split('.')[0]) < 8
4064        )
4065    except Exception:
4066        is_old_mysql = False
4067
4068    src_query = f"""
4069        SELECT
4070            {cols_list_str},
4071            ROW_NUMBER() OVER (
4072                PARTITION BY
4073                {index_list_str}
4074                ORDER BY {index_list_str_ordered}
4075            ) AS {duplicate_row_number_name}
4076        FROM {pipe_table_name}
4077    """
4078    duplicates_cte_subquery = format_cte_subquery(
4079        src_query,
4080        self.flavor,
4081        sub_name = 'src',
4082        cols_to_select = cols_list_str,
4083    ) + f"""
4084        WHERE {duplicate_row_number_name} = 1
4085        """
4086    old_mysql_query = (
4087        f"""
4088        SELECT
4089            {index_list_str}
4090        FROM (
4091          SELECT
4092            {index_list_str},
4093            IF(
4094                @{previous_row_number_name} <> {index_list_str.replace(', ', ' + ')},
4095                @{duplicate_row_number_name} := 0,
4096                @{duplicate_row_number_name}
4097            ),
4098            @{previous_row_number_name} := {index_list_str.replace(', ', ' + ')},
4099            @{duplicate_row_number_name} := @{duplicate_row_number_name} + 1 AS """
4100        + f"""{duplicate_row_number_name}
4101          FROM
4102            {pipe_table_name},
4103            (
4104                SELECT @{duplicate_row_number_name} := 0
4105            ) AS {duplicate_row_number_name},
4106            (
4107                SELECT @{previous_row_number_name} := '{get_null_replacement('str', 'mysql')}'
4108            ) AS {previous_row_number_name}
4109          ORDER BY {index_list_str_ordered}
4110        ) AS t
4111        WHERE {duplicate_row_number_name} = 1
4112        """
4113    )
4114    if is_old_mysql:
4115        duplicates_cte_subquery = old_mysql_query
4116
4117    session_id = generate_password(3)
4118
4119    dedup_table = self.get_temporary_target(pipe.target, transact_id=session_id, label='dedup')
4120    temp_old_table = self.get_temporary_target(pipe.target, transact_id=session_id, label='old')
4121    temp_old_table_name = sql_item_name(temp_old_table, self.flavor, self.get_pipe_schema(pipe))
4122
4123    create_temporary_table_query = get_create_table_query(
4124        duplicates_cte_subquery,
4125        dedup_table,
4126        self.flavor,
4127    ) + f"""
4128    ORDER BY {index_list_str_ordered}
4129    """
4130    if_exists_str = "IF EXISTS" if self.flavor in DROP_IF_EXISTS_FLAVORS else ""
4131    alter_queries = flatten_list([
4132        get_rename_table_queries(
4133            pipe.target,
4134            temp_old_table,
4135            self.flavor,
4136            schema=self.get_pipe_schema(pipe),
4137        ),
4138        get_rename_table_queries(
4139            dedup_table,
4140            pipe.target,
4141            self.flavor,
4142            schema=None,
4143            new_schema=self.get_pipe_schema(pipe),
4144        ),
4145        f"DROP TABLE {if_exists_str} {temp_old_table_name}",
4146    ])
4147
4148    self._log_temporary_tables_creation(temp_old_table, create=(not pipe.temporary), debug=debug)
4149    create_temporary_result = self.execute(create_temporary_table_query, debug=debug)
4150    if create_temporary_result is None:
4151        return False, f"Failed to deduplicate table {pipe_table_name}."
4152
4153    results = self.exec_queries(
4154        alter_queries,
4155        break_on_error=True,
4156        rollback=True,
4157        debug=debug,
4158    )
4159
4160    fail_query = None
4161    for result, query in zip(results, alter_queries):
4162        if result is None:
4163            fail_query = query
4164            break
4165    success = fail_query is None
4166
4167    new_rowcount = (
4168        self.value(get_rowcount_query, debug=debug)
4169        if success
4170        else None
4171    )
4172
4173    msg = (
4174        (
4175            f"Successfully deduplicated table {pipe_table_name}"
4176            + (
4177                f"\nfrom {old_rowcount:,} to {new_rowcount:,} rows"
4178                if old_rowcount != new_rowcount
4179                else ''
4180            ) + '.'
4181        )
4182        if success
4183        else f"Failed to execute query:\n{fail_query}"
4184    )
4185    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]:
3328def get_pipe_table(
3329    self,
3330    pipe: mrsm.Pipe,
3331    debug: bool = False,
3332) -> Union['sqlalchemy.Table', None]:
3333    """
3334    Return the `sqlalchemy.Table` object for a `mrsm.Pipe`.
3335
3336    Parameters
3337    ----------
3338    pipe: mrsm.Pipe:
3339        The pipe in question.
3340
3341    Returns
3342    -------
3343    A `sqlalchemy.Table` object. 
3344
3345    """
3346    from meerschaum.utils.sql import get_sqlalchemy_table
3347    if not pipe.exists(debug=debug):
3348        return None
3349
3350    return get_sqlalchemy_table(
3351        pipe.target,
3352        connector=self,
3353        schema=self.get_pipe_schema(pipe),
3354        debug=debug,
3355        refresh=True,
3356    )

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]:
3359def get_pipe_columns_types(
3360    self,
3361    pipe: mrsm.Pipe,
3362    debug: bool = False,
3363) -> Dict[str, str]:
3364    """
3365    Get the pipe's columns and types.
3366
3367    Parameters
3368    ----------
3369    pipe: mrsm.Pipe:
3370        The pipe to get the columns for.
3371
3372    Returns
3373    -------
3374    A dictionary of columns names (`str`) and types (`str`).
3375
3376    Examples
3377    --------
3378    >>> conn.get_pipe_columns_types(pipe)
3379    {
3380      'dt': 'TIMESTAMP WITHOUT TIMEZONE',
3381      'id': 'BIGINT',
3382      'val': 'DOUBLE PRECISION',
3383    }
3384    >>> 
3385    """
3386    from meerschaum.utils.sql import get_table_cols_types
3387    if not pipe.exists(debug=debug):
3388        return {}
3389
3390    if self.flavor not in ('oracle', 'mysql', 'mariadb', 'sqlite', 'geopackage'):
3391        return get_table_cols_types(
3392            pipe.target,
3393            self,
3394            flavor=self.flavor,
3395            schema=self.get_pipe_schema(pipe),
3396            debug=debug,
3397        )
3398
3399    if debug:
3400        dprint(f"Fetching columns_types for {pipe} with via SQLAlchemy table.")
3401
3402    table_columns = {}
3403    try:
3404        pipe_table = self.get_pipe_table(pipe, debug=debug)
3405        if pipe_table is None:
3406            return {}
3407
3408        if debug:
3409            dprint("Found columns:")
3410            mrsm.pprint(dict(pipe_table.columns))
3411
3412        for col in pipe_table.columns:
3413            table_columns[str(col.name)] = str(col.type)
3414    except Exception as e:
3415        traceback.print_exc()
3416        warn(e)
3417        table_columns = {}
3418
3419    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']":
3914def get_to_sql_dtype(
3915    self,
3916    pipe: 'mrsm.Pipe',
3917    df: 'pd.DataFrame',
3918    update_dtypes: bool = True,
3919) -> Dict[str, 'sqlalchemy.sql.visitors.TraversibleType']:
3920    """
3921    Given a pipe and DataFrame, return the `dtype` dictionary for `to_sql()`.
3922
3923    Parameters
3924    ----------
3925    pipe: mrsm.Pipe
3926        The pipe which may contain a `dtypes` parameter.
3927
3928    df: pd.DataFrame
3929        The DataFrame to be pushed via `to_sql()`.
3930
3931    update_dtypes: bool, default True
3932        If `True`, patch the pipe's dtypes onto the DataFrame's dtypes.
3933
3934    Returns
3935    -------
3936    A dictionary with `sqlalchemy` datatypes.
3937
3938    Examples
3939    --------
3940    >>> import pandas as pd
3941    >>> import meerschaum as mrsm
3942    >>> 
3943    >>> conn = mrsm.get_connector('sql:memory')
3944    >>> df = pd.DataFrame([{'a': {'b': 1}}])
3945    >>> pipe = mrsm.Pipe('a', 'b', dtypes={'a': 'json'})
3946    >>> get_to_sql_dtype(pipe, df)
3947    {'a': <class 'sqlalchemy.sql.sqltypes.JSON'>}
3948    """
3949    from meerschaum.utils.dataframe import get_special_cols
3950    from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type
3951    df_dtypes = {
3952        col: str(typ)
3953        for col, typ in df.dtypes.items()
3954    }
3955    special_cols = get_special_cols(df)
3956    df_dtypes.update(special_cols)
3957
3958    if update_dtypes:
3959        df_dtypes.update(pipe.dtypes)
3960
3961    return {
3962        col: get_db_type_from_pd_type(typ, self.flavor, as_sqlalchemy=True)
3963        for col, typ in df_dtypes.items()
3964        if col and typ
3965    }

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]:
4188def get_pipe_schema(self, pipe: mrsm.Pipe) -> Union[str, None]:
4189    """
4190    Return the schema to use for this pipe.
4191    First check `pipe.parameters['schema']`, then check `self.schema`.
4192
4193    Parameters
4194    ----------
4195    pipe: mrsm.Pipe
4196        The pipe which may contain a configured schema.
4197
4198    Returns
4199    -------
4200    A schema string or `None` if nothing is configured.
4201    """
4202    if self.flavor in ('sqlite', 'geopackage'):
4203        return self.schema
4204    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]:
1704def create_pipe_table_from_df(
1705    self,
1706    pipe: mrsm.Pipe,
1707    df: 'pd.DataFrame',
1708    debug: bool = False,
1709) -> mrsm.SuccessTuple:
1710    """
1711    Create a pipe's table from its configured dtypes and an incoming dataframe.
1712    """
1713    from meerschaum.utils.dataframe import get_special_cols
1714    from meerschaum.utils.sql import (
1715        get_create_table_queries,
1716        sql_item_name,
1717        get_create_schema_if_not_exists_queries,
1718    )
1719    from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type
1720    if self.flavor == 'geopackage':
1721        init_success, init_msg = self._init_geopackage_pipe(df, pipe, debug=debug)
1722        if not init_success:
1723            return init_success, init_msg
1724
1725    primary_key = pipe.columns.get('primary', None)
1726    primary_key_typ = (
1727        pipe.dtypes.get(primary_key, str(df.dtypes.get(primary_key, 'int')))
1728        if primary_key
1729        else None
1730    )
1731    primary_key_db_type = (
1732        get_db_type_from_pd_type(primary_key_typ, self.flavor)
1733        if primary_key
1734        else None
1735    )
1736    dt_col = pipe.columns.get('datetime', None)
1737    new_dtypes = {
1738        **{
1739            col: str(typ)
1740            for col, typ in df.dtypes.items()
1741        },
1742        **{
1743            col: str(df.dtypes.get(col, 'int'))
1744            for col_ix, col in pipe.columns.items()
1745            if col and col_ix != 'primary'
1746        },
1747        **get_special_cols(df),
1748        **pipe.dtypes
1749    }
1750    autoincrement = (
1751        pipe.parameters.get('autoincrement', False)
1752        or (primary_key and primary_key not in new_dtypes)
1753    )
1754    if autoincrement:
1755        _ = new_dtypes.pop(primary_key, None)
1756
1757    schema = self.get_pipe_schema(pipe)
1758
1759    ### When supported (TimescaleDB 2.21+), create the hypertable declaratively via
1760    ### `CREATE TABLE ... WITH (tsdb.hypertable, ...)`. Fall back to a plain `CREATE TABLE`
1761    ### (and the `create_hypertable()` call in `get_create_index_queries`) if it fails.
1762    hypertable = (
1763        self.flavor in ('timescaledb', 'timescaledb-ha')
1764        and pipe.parameters.get('hypertable', True)
1765        and dt_col is not None
1766    )
1767
1768    ### Use the declarative `CREATE TABLE ... WITH (tsdb.hypertable, ...)` path only when Hypercore
1769    ### is enabled (the default). Declarative creation enables the columnstore (via the
1770    ### `segmentby`/`orderby` options) AND makes TimescaleDB auto-install a columnstore policy —
1771    ### exactly the Hypercore behavior we want. With `hypercore=False`, fall back to a plain table
1772    ### plus the `create_hypertable()` call during index creation, which adds NO columnstore policy,
1773    ### keeping `hypercore` a true opt-out (a plain row-store hypertable).
1774    hypercore = hypertable and pipe.parameters.get('hypercore', True)
1775    hypertable_chunk_interval = None
1776    hypertable_segmentby = None
1777    hypertable_orderby = None
1778    if hypercore:
1779        chunk_interval = pipe.get_chunk_interval(debug=debug)
1780        hypertable_chunk_interval = (
1781            f'{chunk_interval}'
1782            if isinstance(chunk_interval, int)
1783            else f'{int(chunk_interval.total_seconds() / 60)} minutes'
1784        )
1785        _compress_settings = self._get_compress_settings(pipe)
1786        hypertable_segmentby = _compress_settings['segmentby'] or None
1787        hypertable_orderby = _compress_settings['orderby'] or None
1788
1789    ### Native range partitioning (non-TimescaleDB flavors); a no-op column for others.
1790    partition_by_column = self._get_partition_column(pipe)
1791    ### MySQL/MariaDB require the initial partitions declared inline at `CREATE TABLE`
1792    ### (an empty RANGE-partitioned table is invalid); compute them from the creation df.
1793    partition_bounds = (
1794        self._get_initial_partition_bounds(pipe, df, debug=debug)
1795        if (partition_by_column is not None and self.flavor in ('mysql', 'mariadb'))
1796        else None
1797    )
1798    ### A MySQL RANGE table needs at least one inline partition; if the creation df has no
1799    ### datetime values, fall back to a plain table (rare — `is_new` normally implies rows).
1800    if partition_by_column is not None and self.flavor in ('mysql', 'mariadb') and not partition_bounds:
1801        partition_by_column = None
1802
1803    ### MSSQL partitions via a function + scheme created before the table; its clustered index is
1804    ### placed on the scheme (passed as `partition_scheme_name`).
1805    partition_scheme_name = None
1806    partition_creation_queries = []
1807    if partition_by_column is not None and self.flavor == 'mssql':
1808        partition_scheme_name = self._partition_scheme_name(pipe)
1809        partition_creation_queries = self._get_mssql_partition_creation_queries(
1810            pipe, df, debug=debug
1811        )
1812
1813    def _build_create_table_queries(_hypertable_chunk_interval):
1814        _queries = get_create_table_queries(
1815            new_dtypes,
1816            pipe.target,
1817            self.flavor,
1818            schema=schema,
1819            primary_key=primary_key,
1820            primary_key_db_type=primary_key_db_type,
1821            datetime_column=dt_col,
1822            hypertable_chunk_interval=_hypertable_chunk_interval,
1823            hypertable_segmentby=(hypertable_segmentby if _hypertable_chunk_interval else None),
1824            hypertable_orderby=(hypertable_orderby if _hypertable_chunk_interval else None),
1825            partition_by_column=partition_by_column,
1826            partition_bounds=partition_bounds,
1827            partition_scheme_name=partition_scheme_name,
1828        )
1829        if partition_creation_queries:
1830            _queries = partition_creation_queries + _queries
1831        if schema:
1832            _queries = (
1833                get_create_schema_if_not_exists_queries(schema, self.flavor)
1834                + _queries
1835            )
1836        return _queries
1837
1838    create_table_queries = _build_create_table_queries(hypertable_chunk_interval)
1839    success = all(
1840        self.exec_queries(
1841            create_table_queries,
1842            break_on_error=True,
1843            rollback=True,
1844            silent=hypercore,
1845            debug=debug,
1846        )
1847    )
1848    if not success and hypercore:
1849        ### Declarative hypertable syntax unsupported; retry as a plain table.
1850        ### `create_hypertable()` runs later during index creation.
1851        create_table_queries = _build_create_table_queries(None)
1852        success = all(
1853            self.exec_queries(create_table_queries, break_on_error=True, rollback=True, debug=debug)
1854        )
1855    ### Declarative Hypercore creation auto-installs a columnstore policy, which needs an
1856    ### `integer_now` function on an integer axis.
1857    if success and hypercore and not self.set_integer_now_func(pipe, debug=debug):
1858        warn(
1859            f"Could not register an `integer_now` function for {pipe}; "
1860            "its columnstore policy will fail on every run.",
1861            stack=False,
1862        )
1863
1864    target_name = sql_item_name(pipe.target, schema=self.get_pipe_schema(pipe), flavor=self.flavor)
1865    msg = (
1866        "Success"
1867        if success
1868        else f"Failed to create {target_name}."
1869    )
1870    if success and self.flavor == 'geopackage':
1871        return self._init_geopackage_pipe(df, pipe, debug=debug)
1872
1873    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]]]:
3422def get_pipe_columns_indices(
3423    self,
3424    pipe: mrsm.Pipe,
3425    debug: bool = False,
3426) -> Dict[str, List[Dict[str, str]]]:
3427    """
3428    Return a dictionary mapping columns to the indices created on those columns.
3429
3430    Parameters
3431    ----------
3432    pipe: mrsm.Pipe
3433        The pipe to be queried against.
3434
3435    Returns
3436    -------
3437    A dictionary mapping columns names to lists of dictionaries.
3438    The dictionaries in the lists contain the name and type of the indices.
3439    """
3440    if pipe.__dict__.get('_skip_check_indices', False):
3441        return {}
3442
3443    from meerschaum.utils.sql import get_table_cols_indices
3444    return get_table_cols_indices(
3445        pipe.target,
3446        self,
3447        flavor=self.flavor,
3448        schema=self.get_pipe_schema(pipe),
3449        debug=debug,
3450    )

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:
4207@staticmethod
4208def get_temporary_target(
4209    target: str,
4210    transact_id: Optional[str] = None,
4211    label: Optional[str] = None,
4212    separator: Optional[str] = None,
4213) -> str:
4214    """
4215    Return a unique(ish) temporary target for a pipe.
4216    """
4217    from meerschaum.utils.misc import generate_password
4218    temp_target_cf = (
4219        mrsm.get_config('system', 'connectors', 'sql', 'instance', 'temporary_target') or {}
4220    )
4221    transaction_id_len = temp_target_cf.get('transaction_id_length', 3)
4222    transact_id = transact_id or generate_password(transaction_id_len)
4223    temp_prefix = temp_target_cf.get('prefix', '_')
4224    separator = separator or temp_target_cf.get('separator', '_')
4225    return (
4226        temp_prefix
4227        + target
4228        + separator
4229        + transact_id
4230        + ((separator + label) if label else '')
4231    )

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]:
361def create_pipe_indices(
362    self,
363    pipe: mrsm.Pipe,
364    columns: Optional[List[str]] = None,
365    debug: bool = False,
366) -> SuccessTuple:
367    """
368    Create a pipe's indices.
369    """
370    success = self.create_indices(pipe, columns=columns, debug=debug)
371    msg = (
372        "Success"
373        if success
374        else f"Failed to create indices for {pipe}."
375    )
376    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]:
417def drop_pipe_indices(
418    self,
419    pipe: mrsm.Pipe,
420    columns: Optional[List[str]] = None,
421    debug: bool = False,
422) -> SuccessTuple:
423    """
424    Drop a pipe's indices.
425    """
426    success = self.drop_indices(pipe, columns=columns, debug=debug)
427    msg = (
428        "Success"
429        if success
430        else f"Failed to drop indices for {pipe}."
431    )
432    return success, msg

Drop a pipe's indices.

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

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'}
>>>