meerschaum.connectors.sql
Subpackage for SQLConnector subclass
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/
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 thebootstrap connectorscommand. - wait (bool, default False):
If
True, block until a database connection has been made. Defaults toFalse. - connect (bool, default False):
If
True, immediately attempt to connect the database and raise a warning if the connection fails. Defaults toFalse. - 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.
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
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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):
ListorDictof parameters to pass topandas.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.
Nonewill 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_hookis 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-chunksfor an example. NOTE:as_iteratorMUST be False (default). as_hook_results (bool, default False): If
True, return aListof the outputs of the hook function. Only applicable ifchunk_hookis not None.NOTE:
as_iteratorMUST beFalse(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
chunksizeof1000andchunksof100. - 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.chunksizemust not beNone(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 toFalse.
Returns
- A
pd.DataFrame(default case), or an iterator, or a list of dataframes / iterators, - or
Noneif something breaks.
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
- query (str): The SQL query to execute.
- *args (Any):
The arguments passed to
meerschaum.connectors.sql.SQLConnector.execifuse_pandasisFalse(default) or tomeerschaum.connectors.sql.SQLConnector.read. - use_pandas (bool, default False):
If
True, usemeerschaum.connectors.SQLConnector.read, otherwise usemeerschaum.connectors.sql.SQLConnector.exec(default). NOTE: This is alwaysTruefor DuckDB. - **kw (Any):
See
args.
Returns
- Any value returned from the query.
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
queryis a list or tuple, callself.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 ifqueryis 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 ifqueryis a list of strings. - with_connection (bool, default False):
If
True, return a tuple including the connection object. This does not apply ifqueryis a list of strings.
Returns
- The
sqlalchemyresult object, or a tuple with the connection ifwith_connectionis provided.
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)
An alias for meerschaum.connectors.sql.SQLConnector.exec.
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 abool. Defaults toFalse. - as_dict (bool, default False):
If
True, return a dictionary of transaction information. The keys aresuccess,msg,start,end,duration,num_rows,chunksize,method, andtarget. - kw (Any):
Additional arguments will be passed to the DataFrame's
to_sqlfunction
Returns
- Either a
boolor aSuccessTuple(depends onas_tuple).
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_errorisTrue, rollback the transaction if a query fails. - silent (bool, default False):
If
True, suppress warnings.
Returns
- A list of SQLAlchemy results.
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.Connectionobject.
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
- **kw:: The keyword arguments are passed to
meerschaum.connectors.poll.retry_connect.
Returns
Trueif a connection is made, otherwiseFalseorNonein case of failure.
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
fetchmetadata.- 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
beginto search for data (optional).
- How many minutes before
- pipe.columns['datetime']: str
- begin (Union[datetime, int, str, None], default None):
Most recent datatime to search for data.
If
backtrack_minutesis provided, subtractbacktrack_minutes. - end (Union[datetime, int, str, None], default None):
The latest datetime to search for data.
If
endisNone, 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.
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.
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.
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
intof the number of bytes occupied by the target table, - or
Noneif the size could not be determined.
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
SuccessTupleindicating success, including the amount of disk reclaimed.
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
SuccessTupleindicating success, including the change in disk size.
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
SuccessTupleindicating success.
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.
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), runVACUUM 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
SuccessTupleindicating success, including the amount of disk reclaimed.
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
SuccessTupleindicating success.
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 (Noneif unknown).interval: the physical partition width (timedelta, epoch-int, orNone).
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_dfand_create_missing_partitionspaths, 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
SuccessTupleindicating success.
149def fetch_pipes_keys( 150 self, 151 connector_keys: Optional[List[str]] = None, 152 metric_keys: Optional[List[str]] = None, 153 location_keys: Optional[List[str]] = None, 154 tags: Optional[List[str]] = None, 155 params: Optional[Dict[str, Any]] = None, 156 debug: bool = False, 157) -> Dict[ 158 int, Tuple[str, str, Union[str, None], Dict[str, Any]] 159 ]: 160 """ 161 Return a dictionary mapping pipe IDs to key tuples corresponding to the parameters provided. 162 163 Parameters 164 ---------- 165 connector_keys: Optional[List[str]], default None 166 List of connector_keys to search by. 167 168 metric_keys: Optional[List[str]], default None 169 List of metric_keys to search by. 170 171 location_keys: Optional[List[str]], default None 172 List of location_keys to search by. 173 174 tags: Optional[List[str]], default None 175 List of pipes to search by. 176 177 params: Optional[Dict[str, Any]], default None 178 Dictionary of additional parameters to search by. 179 E.g. `--params pipe_id:1` 180 181 debug: bool, default False 182 Verbosity toggle. 183 184 Returns 185 ------- 186 A list of tuples of pipes' keys and parameters (connector_keys, metric_key, location_key, parameters). 187 """ 188 from meerschaum.utils.packages import attempt_import 189 from meerschaum.utils.misc import separate_negation_values 190 from meerschaum.utils.sql import ( 191 OMIT_NULLSFIRST_FLAVORS, 192 table_exists, 193 json_flavors, 194 ) 195 from meerschaum._internal.static import STATIC_CONFIG 196 import json 197 from copy import deepcopy 198 sqlalchemy, sqlalchemy_sql_functions = attempt_import( 199 'sqlalchemy', 200 'sqlalchemy.sql.functions', lazy=False, 201 ) 202 coalesce = sqlalchemy_sql_functions.coalesce 203 204 if connector_keys is None: 205 connector_keys = [] 206 if metric_keys is None: 207 metric_keys = [] 208 if location_keys is None: 209 location_keys = [] 210 else: 211 location_keys = [ 212 ( 213 lk 214 if lk not in ('[None]', 'None', 'null') 215 else 'None' 216 ) 217 for lk in location_keys 218 ] 219 if tags is None: 220 tags = [] 221 222 if params is None: 223 params = {} 224 225 ### Add three primary keys to params dictionary 226 ### (separated for convenience of arguments). 227 cols = { 228 'connector_keys': [str(ck) for ck in connector_keys], 229 'metric_key': [str(mk) for mk in metric_keys], 230 'location_key': [str(lk) for lk in location_keys], 231 } 232 233 ### Make deep copy so we don't mutate this somewhere else. 234 parameters = deepcopy(params) 235 for col, vals in cols.items(): 236 if vals not in [[], ['*']]: 237 parameters[col] = vals 238 239 if not table_exists('mrsm_pipes', self, schema=self.instance_schema, debug=debug): 240 return {} 241 242 from meerschaum.connectors.sql.tables import get_tables 243 pipes_tbl = get_tables(mrsm_instance=self, create=False, debug=debug)['pipes'] 244 245 _params = {} 246 for k, v in parameters.items(): 247 _v = json.dumps(v) if isinstance(v, dict) else v 248 _params[k] = _v 249 250 negation_prefix = STATIC_CONFIG['system']['fetch_pipes_keys']['negation_prefix'] 251 ### Parse regular params. 252 ### If a param begins with '_', negate it instead. 253 _where = [ 254 ( 255 (coalesce(pipes_tbl.c[key], 'None') == val) 256 if not str(val).startswith(negation_prefix) 257 else (pipes_tbl.c[key] != key) 258 ) for key, val in _params.items() 259 if not isinstance(val, (list, tuple)) and key in pipes_tbl.c 260 ] 261 if self.flavor in json_flavors: 262 sqlalchemy_dialects = mrsm.attempt_import('sqlalchemy.dialects', lazy=False) 263 JSONB = sqlalchemy_dialects.postgresql.JSONB 264 else: 265 JSONB = sqlalchemy.String 266 267 select_cols = ( 268 [ 269 pipes_tbl.c.pipe_id, 270 pipes_tbl.c.connector_keys, 271 pipes_tbl.c.metric_key, 272 pipes_tbl.c.location_key, 273 pipes_tbl.c.parameters, 274 ] 275 ) 276 277 q = sqlalchemy.select(*select_cols).where(sqlalchemy.and_(True, *_where)) 278 for c, vals in cols.items(): 279 if not isinstance(vals, (list, tuple)) or not vals or c not in pipes_tbl.c: 280 continue 281 _in_vals, _ex_vals = separate_negation_values(vals) 282 q = q.where(coalesce(pipes_tbl.c[c], 'None').in_(_in_vals)) if _in_vals else q 283 q = q.where(coalesce(pipes_tbl.c[c], 'None').not_in(_ex_vals)) if _ex_vals else q 284 285 ### Finally, parse tags. 286 tag_groups = [tag.split(',') for tag in tags] 287 in_ex_tag_groups = [separate_negation_values(tag_group) for tag_group in tag_groups] 288 289 ors, nands = [], [] 290 if self.flavor in json_flavors: 291 tags_jsonb = pipes_tbl.c['parameters'].cast(JSONB).op('->')('tags').cast(JSONB) 292 for _in_tags, _ex_tags in in_ex_tag_groups: 293 if _in_tags: 294 ors.append( 295 sqlalchemy.and_( 296 tags_jsonb.contains(_in_tags) 297 ) 298 ) 299 for xt in _ex_tags: 300 nands.append( 301 sqlalchemy.not_( 302 sqlalchemy.and_( 303 tags_jsonb.contains([xt]) 304 ) 305 ) 306 ) 307 else: 308 for _in_tags, _ex_tags in in_ex_tag_groups: 309 sub_ands = [] 310 for nt in _in_tags: 311 sub_ands.append( 312 sqlalchemy.cast( 313 pipes_tbl.c['parameters'], 314 sqlalchemy.String, 315 ).like(f'%"tags":%"{nt}"%') 316 ) 317 if sub_ands: 318 ors.append(sqlalchemy.and_(*sub_ands)) 319 320 for xt in _ex_tags: 321 nands.append( 322 sqlalchemy.cast( 323 pipes_tbl.c['parameters'], 324 sqlalchemy.String, 325 ).not_like(f'%"tags":%"{xt}"%') 326 ) 327 328 q = q.where(sqlalchemy.and_(*nands)) if nands else q 329 q = q.where(sqlalchemy.or_(*ors)) if ors else q 330 loc_asc = sqlalchemy.asc(pipes_tbl.c['location_key']) 331 if self.flavor not in OMIT_NULLSFIRST_FLAVORS: 332 loc_asc = sqlalchemy.nullsfirst(loc_asc) 333 q = q.order_by( 334 sqlalchemy.asc(pipes_tbl.c['connector_keys']), 335 sqlalchemy.asc(pipes_tbl.c['metric_key']), 336 loc_asc, 337 ) 338 339 ### execute the query and return a list of tuples 340 if debug: 341 dprint(q) 342 try: 343 rows = ( 344 self.execute(q).fetchall() 345 if self.flavor != 'duckdb' 346 else [ 347 ( 348 row['pipe_id'], 349 row['connector_keys'], 350 row['metric_key'], 351 row['location_key'], 352 row['parameters'], 353 ) 354 for row in self.read(q).to_dict(orient='records') 355 ] 356 ) 357 except Exception as e: 358 error(str(e)) 359 360 return { 361 row[0]: row[1:] 362 for row in rows 363 }
Return a dictionary mapping pipe IDs to key tuples corresponding to the parameters provided.
Parameters
- connector_keys (Optional[List[str]], default None): List of connector_keys to search by.
- metric_keys (Optional[List[str]], default None): List of metric_keys to search by.
- location_keys (Optional[List[str]], default None): List of location_keys to search by.
- tags (Optional[List[str]], default None): List of pipes to search by.
- params (Optional[Dict[str, Any]], default None):
Dictionary of additional parameters to search by.
E.g.
--params pipe_id:1 - debug (bool, default False): Verbosity toggle.
Returns
- A list of tuples of pipes' keys and parameters (connector_keys, metric_key, location_key, parameters).
384def create_indices( 385 self, 386 pipe: mrsm.Pipe, 387 columns: Optional[List[str]] = None, 388 indices: Optional[List[str]] = None, 389 debug: bool = False 390) -> bool: 391 """ 392 Create a pipe's indices. 393 """ 394 if pipe.__dict__.get('_skip_check_indices', False): 395 return True 396 397 if debug: 398 dprint(f"Creating indices for {pipe}...") 399 400 if not pipe.indices: 401 warn(f"{pipe} has no index columns; skipping index creation.", stack=False) 402 return True 403 404 cols_to_include = set((columns or []) + (indices or [])) or None 405 406 pipe._clear_cache_key('_columns_indices', debug=debug) 407 ix_queries = { 408 col: queries 409 for col, queries in self.get_create_index_queries(pipe, debug=debug).items() 410 if cols_to_include is None or col in cols_to_include 411 } 412 success = True 413 for col, queries in ix_queries.items(): 414 ix_success = all(self.exec_queries(queries, debug=debug, silent=False)) 415 success = success and ix_success 416 if not ix_success: 417 warn(f"Failed to create index on column: {col}") 418 419 return success
Create a pipe's indices.
440def drop_indices( 441 self, 442 pipe: mrsm.Pipe, 443 columns: Optional[List[str]] = None, 444 indices: Optional[List[str]] = None, 445 debug: bool = False 446) -> bool: 447 """ 448 Drop a pipe's indices. 449 """ 450 if debug: 451 dprint(f"Dropping indices for {pipe}...") 452 453 if not pipe.indices: 454 warn(f"No indices to drop for {pipe}.", stack=False) 455 return False 456 457 cols_to_include = set((columns or []) + (indices or [])) or None 458 459 ix_queries = { 460 col: queries 461 for col, queries in self.get_drop_index_queries(pipe, debug=debug).items() 462 if cols_to_include is None or col in cols_to_include 463 } 464 success = True 465 for col, queries in ix_queries.items(): 466 ix_success = all(self.exec_queries(queries, debug=debug, silent=(not debug))) 467 if not ix_success: 468 success = False 469 if debug: 470 dprint(f"Failed to drop index on column: {col}") 471 return success
Drop a pipe's indices.
537def get_create_index_queries( 538 self, 539 pipe: mrsm.Pipe, 540 debug: bool = False, 541) -> Dict[str, List[str]]: 542 """ 543 Return a dictionary mapping columns to a `CREATE INDEX` or equivalent query. 544 545 Parameters 546 ---------- 547 pipe: mrsm.Pipe 548 The pipe to which the queries will correspond. 549 550 Returns 551 ------- 552 A dictionary of index names mapping to lists of queries. 553 """ 554 ### NOTE: Due to recent breaking changes in DuckDB, indices don't behave properly. 555 if self.flavor == 'duckdb': 556 return {} 557 from meerschaum.utils.sql import ( 558 sql_item_name, 559 get_distinct_col_count, 560 UPDATE_QUERIES, 561 get_null_replacement, 562 get_create_table_queries, 563 get_rename_table_queries, 564 COALESCE_UNIQUE_INDEX_FLAVORS, 565 ) 566 from meerschaum.utils.dtypes import are_dtypes_equal 567 from meerschaum.utils.dtypes.sql import ( 568 get_db_type_from_pd_type, 569 get_pd_type_from_db_type, 570 AUTO_INCREMENT_COLUMN_FLAVORS, 571 ) 572 from meerschaum.config import get_config 573 index_queries = {} 574 575 upsert = pipe.parameters.get('upsert', False) and (self.flavor + '-upsert') in UPDATE_QUERIES 576 static = pipe.parameters.get('static', False) 577 null_indices = pipe.parameters.get('null_indices', True) 578 index_names = pipe.get_indices() 579 unique_index_name_unquoted = index_names.get('unique', None) or f'IX_{pipe.target}_unique' 580 if upsert: 581 _ = index_names.pop('unique', None) 582 indices = pipe.indices 583 existing_cols_types = pipe.get_columns_types(debug=debug) 584 existing_cols_pd_types = { 585 col: get_pd_type_from_db_type(typ) 586 for col, typ in existing_cols_types.items() 587 } 588 existing_cols_indices = self.get_pipe_columns_indices(pipe, debug=debug) 589 existing_ix_names = set() 590 existing_primary_keys = [] 591 existing_clustered_primary_keys = [] 592 for col, col_indices in existing_cols_indices.items(): 593 for col_ix_doc in col_indices: 594 existing_ix_names.add(col_ix_doc.get('name', '').lower()) 595 if col_ix_doc.get('type', None) == 'PRIMARY KEY': 596 existing_primary_keys.append(col.lower()) 597 if col_ix_doc.get('clustered', True): 598 existing_clustered_primary_keys.append(col.lower()) 599 600 _datetime = pipe.get_columns('datetime', error=False) 601 _datetime_name = ( 602 sql_item_name(_datetime, self.flavor, None) 603 if _datetime is not None else None 604 ) 605 _datetime_index_name = ( 606 sql_item_name(index_names['datetime'], flavor=self.flavor, schema=None) 607 if index_names.get('datetime', None) 608 else None 609 ) 610 _id = pipe.get_columns('id', error=False) 611 _id_name = ( 612 sql_item_name(_id, self.flavor, None) 613 if _id is not None 614 else None 615 ) 616 primary_key = pipe.columns.get('primary', None) 617 primary_key_name = ( 618 sql_item_name(primary_key, flavor=self.flavor, schema=None) 619 if primary_key 620 else None 621 ) 622 autoincrement = ( 623 pipe.parameters.get('autoincrement', False) 624 or ( 625 primary_key is not None 626 and primary_key not in existing_cols_pd_types 627 ) 628 ) 629 primary_key_db_type = ( 630 get_db_type_from_pd_type(pipe.dtypes.get(primary_key, 'int') or 'int', self.flavor) 631 if primary_key 632 else None 633 ) 634 primary_key_constraint_name = ( 635 sql_item_name(f'PK_{pipe.target}', self.flavor, None) 636 if primary_key is not None 637 else None 638 ) 639 primary_key_clustered = "CLUSTERED" if _datetime is None else "NONCLUSTERED" 640 datetime_clustered = ( 641 "CLUSTERED" 642 if not existing_clustered_primary_keys and _datetime is not None 643 else "NONCLUSTERED" 644 ) 645 include_columns_str = "\n ,".join( 646 [ 647 sql_item_name(col, flavor=self.flavor) for col in existing_cols_types 648 if col != _datetime 649 ] 650 ).rstrip(',') 651 include_clause = ( 652 ( 653 f"\nINCLUDE (\n {include_columns_str}\n)" 654 ) 655 if datetime_clustered == 'NONCLUSTERED' 656 else '' 657 ) 658 659 _id_index_name = ( 660 sql_item_name(index_names['id'], self.flavor, None) 661 if index_names.get('id', None) 662 else None 663 ) 664 _pipe_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe)) 665 _create_space_partition = get_config('system', 'experimental', 'space') 666 667 ### create datetime index 668 dt_query = None 669 if _datetime is not None: 670 if ( 671 self.flavor in ('timescaledb', 'timescaledb-ha') 672 and pipe.parameters.get('hypertable', True) 673 ): 674 _id_count = ( 675 get_distinct_col_count(_id, f"SELECT {_id_name} FROM {_pipe_name}", self) 676 if (_id is not None and _create_space_partition) else None 677 ) 678 679 chunk_interval = pipe.get_chunk_interval(debug=debug) 680 chunk_interval_minutes = ( 681 chunk_interval 682 if isinstance(chunk_interval, int) 683 else int(chunk_interval.total_seconds() / 60) 684 ) 685 chunk_time_interval = ( 686 f"INTERVAL '{chunk_interval_minutes} MINUTES'" 687 if isinstance(chunk_interval, timedelta) 688 else f'{chunk_interval_minutes}' 689 ) 690 691 dt_query = ( 692 f"SELECT public.create_hypertable('{_pipe_name}', " + 693 f"'{_datetime}', " 694 + ( 695 f"'{_id}', {_id_count}, " if (_id is not None and _create_space_partition) 696 else '' 697 ) 698 + f'chunk_time_interval => {chunk_time_interval}, ' 699 + 'if_not_exists => true, ' 700 + "migrate_data => true);" 701 ) 702 elif _datetime_index_name and _datetime != primary_key: 703 if self.flavor == 'mssql': 704 dt_query = ( 705 f"CREATE {datetime_clustered} INDEX {_datetime_index_name} " 706 f"\nON {_pipe_name} ({_datetime_name}){include_clause}" 707 ) 708 else: 709 dt_query = ( 710 f"CREATE INDEX {_datetime_index_name} " 711 + f"ON {_pipe_name} ({_datetime_name})" 712 ) 713 714 if dt_query: 715 index_queries[_datetime] = [dt_query] 716 717 primary_queries = [] 718 if ( 719 primary_key is not None 720 and primary_key.lower() not in existing_primary_keys 721 and not static 722 ): 723 if autoincrement and primary_key not in existing_cols_pd_types: 724 autoincrement_str = AUTO_INCREMENT_COLUMN_FLAVORS.get( 725 self.flavor, 726 AUTO_INCREMENT_COLUMN_FLAVORS['default'] 727 ) 728 primary_queries.extend([ 729 ( 730 f"ALTER TABLE {_pipe_name}\n" 731 f"ADD {primary_key_name} {primary_key_db_type} {autoincrement_str}" 732 ), 733 ]) 734 elif not autoincrement and primary_key in existing_cols_pd_types: 735 if self.flavor in ('sqlite', 'geopackage'): 736 new_table_name = sql_item_name( 737 f'_new_{pipe.target}', 738 self.flavor, 739 self.get_pipe_schema(pipe) 740 ) 741 select_cols_str = ', '.join( 742 [ 743 sql_item_name(col, self.flavor, None) 744 for col in existing_cols_types 745 ] 746 ) 747 primary_queries.extend( 748 get_create_table_queries( 749 existing_cols_pd_types, 750 f'_new_{pipe.target}', 751 self.flavor, 752 schema=self.get_pipe_schema(pipe), 753 primary_key=primary_key, 754 ) + [ 755 ( 756 f"INSERT INTO {new_table_name} ({select_cols_str})\n" 757 f"SELECT {select_cols_str}\nFROM {_pipe_name}" 758 ), 759 f"DROP TABLE {_pipe_name}", 760 ] + get_rename_table_queries( 761 f'_new_{pipe.target}', 762 pipe.target, 763 self.flavor, 764 schema=self.get_pipe_schema(pipe), 765 ) 766 ) 767 elif self.flavor == 'oracle': 768 primary_queries.extend([ 769 ( 770 f"ALTER TABLE {_pipe_name}\n" 771 f"MODIFY {primary_key_name} NOT NULL" 772 ), 773 ( 774 f"ALTER TABLE {_pipe_name}\n" 775 f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY ({primary_key_name})" 776 ) 777 ]) 778 elif self.flavor in ('mysql', 'mariadb'): 779 primary_queries.extend([ 780 ( 781 f"ALTER TABLE {_pipe_name}\n" 782 f"MODIFY {primary_key_name} {primary_key_db_type} NOT NULL" 783 ), 784 ( 785 f"ALTER TABLE {_pipe_name}\n" 786 f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY ({primary_key_name})" 787 ) 788 ]) 789 elif self.flavor in ('timescaledb', 'timescaledb-ha'): 790 primary_queries.extend([ 791 ( 792 f"ALTER TABLE {_pipe_name}\n" 793 f"ALTER COLUMN {primary_key_name} SET NOT NULL" 794 ), 795 ( 796 f"ALTER TABLE {_pipe_name}\n" 797 f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY (" + ( 798 f"{_datetime_name}, " if _datetime_name else "" 799 ) + f"{primary_key_name})" 800 ), 801 ]) 802 elif self.flavor in ('citus', 'postgresql', 'duckdb', 'postgis'): 803 primary_queries.extend([ 804 ( 805 f"ALTER TABLE {_pipe_name}\n" 806 f"ALTER COLUMN {primary_key_name} SET NOT NULL" 807 ), 808 ( 809 f"ALTER TABLE {_pipe_name}\n" 810 f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY ({primary_key_name})" 811 ), 812 ]) 813 else: 814 primary_queries.extend([ 815 ( 816 f"ALTER TABLE {_pipe_name}\n" 817 f"ALTER COLUMN {primary_key_name} {primary_key_db_type} NOT NULL" 818 ), 819 ( 820 f"ALTER TABLE {_pipe_name}\n" 821 f"ADD CONSTRAINT {primary_key_constraint_name} PRIMARY KEY {primary_key_clustered} ({primary_key_name})" 822 ), 823 ]) 824 index_queries[primary_key] = primary_queries 825 826 ### create id index 827 if _id_name is not None: 828 if self.flavor in ('timescaledb', 'timescaledb-ha'): 829 ### Already created indices via create_hypertable. 830 id_query = ( 831 None if (_id is not None and _create_space_partition) 832 else ( 833 f"CREATE INDEX IF NOT EXISTS {_id_index_name} ON {_pipe_name} ({_id_name})" 834 if _id is not None 835 else None 836 ) 837 ) 838 pass 839 else: ### mssql, sqlite, etc. 840 id_query = ( 841 None 842 if _is_non_indexable_col(_id, existing_cols_types, self.flavor) 843 else f"CREATE INDEX {_id_index_name} ON {_pipe_name} ({_id_name})" 844 ) 845 846 if id_query is not None: 847 index_queries[_id] = id_query if isinstance(id_query, list) else [id_query] 848 849 ### Create indices for other labels in `pipe.columns`. 850 other_index_names = { 851 ix_key: ix_unquoted 852 for ix_key, ix_unquoted in index_names.items() 853 if ( 854 ix_key not in ('datetime', 'id', 'primary') 855 and ix_unquoted.lower() not in existing_ix_names 856 ) 857 } 858 for ix_key, ix_unquoted in other_index_names.items(): 859 ix_name = sql_item_name(ix_unquoted, self.flavor, None) 860 cols = indices[ix_key] 861 if not isinstance(cols, (list, tuple)): 862 cols = [cols] 863 if ix_key == 'unique' and upsert: 864 continue 865 if self.flavor in ('mysql', 'mariadb', 'mssql'): 866 cols = [ 867 col for col in cols 868 if col and not _is_non_indexable_col( 869 col, existing_cols_types, self.flavor 870 ) 871 ] 872 cols_names = [sql_item_name(col, self.flavor, None) for col in cols if col] 873 if not cols_names: 874 continue 875 876 cols_names_str = ", ".join(cols_names) 877 index_query_params_clause = f" ({cols_names_str})" 878 if self.flavor in ('postgis', 'timescaledb-ha'): 879 for col in cols: 880 col_typ = existing_cols_pd_types.get(cols[0], 'object') 881 if col_typ != 'object' and are_dtypes_equal(col_typ, 'geometry'): 882 index_query_params_clause = f" USING GIST ({cols_names_str})" 883 break 884 885 index_queries[ix_key] = [ 886 f"CREATE INDEX {ix_name} ON {_pipe_name}{index_query_params_clause}" 887 ] 888 889 indices_cols_str = ', '.join( 890 list({ 891 sql_item_name(ix, self.flavor) 892 for ix_key, ix in pipe.columns.items() 893 if ix and ix in existing_cols_types 894 }) 895 ) 896 coalesce_indices_cols_str = ', '.join( 897 [ 898 ( 899 ( 900 "COALESCE(" 901 + sql_item_name(ix, self.flavor) 902 + ", " 903 + get_null_replacement(existing_cols_types[ix], self.flavor) 904 + ") " 905 ) 906 if ix_key != 'datetime' and null_indices 907 else sql_item_name(ix, self.flavor) 908 ) 909 for ix_key, ix in pipe.columns.items() 910 if ix and ix in existing_cols_types 911 ] 912 ) 913 unique_index_name = sql_item_name(unique_index_name_unquoted, self.flavor) 914 constraint_name_unquoted = unique_index_name_unquoted.replace('IX_', 'UQ_') 915 constraint_name = sql_item_name(constraint_name_unquoted, self.flavor) 916 unique_target_exists = ( 917 unique_index_name_unquoted.lower() in existing_ix_names 918 or ( 919 self.flavor in _POSTGRES_UNIQUE_CONSTRAINT_FLAVORS 920 and constraint_name_unquoted.lower() in existing_ix_names 921 ) 922 ) 923 add_constraint_query = ( 924 f"ALTER TABLE {_pipe_name} ADD CONSTRAINT {constraint_name} UNIQUE ({indices_cols_str})" 925 ) 926 unique_index_cols_str = ( 927 indices_cols_str 928 if self.flavor not in COALESCE_UNIQUE_INDEX_FLAVORS or not null_indices 929 else coalesce_indices_cols_str 930 ) 931 create_unique_index_query = ( 932 f"CREATE UNIQUE INDEX {unique_index_name} ON {_pipe_name} ({unique_index_cols_str})" 933 ) 934 constraint_queries = ( 935 [add_constraint_query] 936 if self.flavor in _POSTGRES_UNIQUE_CONSTRAINT_FLAVORS 937 else [create_unique_index_query] 938 ) 939 if self.flavor not in ( 940 'sqlite', 'geopackage', *_POSTGRES_UNIQUE_CONSTRAINT_FLAVORS 941 ): 942 constraint_queries.append(add_constraint_query) 943 if upsert and indices_cols_str and not unique_target_exists: 944 index_queries[unique_index_name] = constraint_queries 945 ### Remove regular indices that cover the same single column as the unique index. 946 ### Some flavors (e.g. Oracle) reject two indices on the same column combination. 947 if unique_index_cols_str == _id_name: 948 index_queries.pop(_id, None) 949 if unique_index_cols_str == _datetime_name: 950 index_queries.pop(_datetime, None) 951 return index_queries
Return a dictionary mapping columns to a CREATE INDEX or equivalent query.
Parameters
- pipe (mrsm.Pipe): The pipe to which the queries will correspond.
Returns
- A dictionary of index names mapping to lists of queries.
954def get_drop_index_queries( 955 self, 956 pipe: mrsm.Pipe, 957 debug: bool = False, 958) -> Dict[str, List[str]]: 959 """ 960 Return a dictionary mapping columns to a `DROP INDEX` or equivalent query. 961 962 Parameters 963 ---------- 964 pipe: mrsm.Pipe 965 The pipe to which the queries will correspond. 966 967 Returns 968 ------- 969 A dictionary of column names mapping to lists of queries. 970 """ 971 ### NOTE: Due to breaking changes within DuckDB, indices must be skipped. 972 if self.flavor == 'duckdb': 973 return {} 974 if not pipe.exists(debug=debug): 975 return {} 976 977 from collections import defaultdict 978 from meerschaum.utils.sql import ( 979 sql_item_name, 980 table_exists, 981 hypertable_queries, 982 DROP_INDEX_IF_EXISTS_FLAVORS, 983 ) 984 drop_queries = defaultdict(lambda: []) 985 schema = self.get_pipe_schema(pipe) 986 index_schema = schema if self.flavor != 'mssql' else None 987 indices = { 988 ix_key: ix 989 for ix_key, ix in pipe.get_indices().items() 990 } 991 cols_indices = pipe.get_columns_indices(debug=debug) 992 existing_indices = set() 993 clustered_ix = None 994 for col, ix_metas in cols_indices.items(): 995 for ix_meta in ix_metas: 996 ix_name = ix_meta.get('name', None) 997 if ix_meta.get('clustered', False): 998 clustered_ix = ix_name 999 existing_indices.add(ix_name.lower()) 1000 pipe_name = sql_item_name(pipe.target, self.flavor, schema) 1001 pipe_name_no_schema = sql_item_name(pipe.target, self.flavor, None) 1002 upsert = pipe.upsert 1003 1004 if self.flavor not in hypertable_queries: 1005 is_hypertable = False 1006 else: 1007 is_hypertable_query = hypertable_queries[self.flavor].format(table_name=pipe_name) 1008 is_hypertable = self.value(is_hypertable_query, silent=True, debug=debug) is not None 1009 1010 if_exists_str = "IF EXISTS " if self.flavor in DROP_INDEX_IF_EXISTS_FLAVORS else "" 1011 if is_hypertable: 1012 nuke_queries = [] 1013 temp_table = '_' + pipe.target + '_temp_migration' 1014 temp_table_name = sql_item_name(temp_table, self.flavor, self.get_pipe_schema(pipe)) 1015 1016 if table_exists(temp_table, self, schema=self.get_pipe_schema(pipe), debug=debug): 1017 nuke_queries.append(f"DROP TABLE {if_exists_str} {temp_table_name}") 1018 nuke_queries += [ 1019 f"SELECT * INTO {temp_table_name} FROM {pipe_name}", 1020 f"DROP TABLE {if_exists_str}{pipe_name}", 1021 f"ALTER TABLE {temp_table_name} RENAME TO {pipe_name_no_schema}", 1022 ] 1023 nuke_ix_keys = ('datetime', 'id') 1024 nuked = False 1025 for ix_key in nuke_ix_keys: 1026 if ix_key in indices and not nuked: 1027 drop_queries[ix_key].extend(nuke_queries) 1028 nuked = True 1029 1030 for ix_key, ix_unquoted in indices.items(): 1031 if ix_key in drop_queries: 1032 continue 1033 is_unique_upsert = ix_key == 'unique' and upsert 1034 constraint_name_unquoted = ix_unquoted.replace('IX_', 'UQ_') 1035 index_exists = ix_unquoted.lower() in existing_indices 1036 ### Other flavors (e.g. Oracle) may back the constraint with the `IX_` index itself, 1037 ### so the `UQ_` name is only listed as an index on PostgreSQL. 1038 constraint_exists = ( 1039 constraint_name_unquoted.lower() in existing_indices 1040 if self.flavor in _POSTGRES_UNIQUE_CONSTRAINT_FLAVORS 1041 else index_exists 1042 ) 1043 if not index_exists and not (is_unique_upsert and constraint_exists): 1044 continue 1045 1046 if ( 1047 is_unique_upsert 1048 and self.flavor not in ('sqlite', 'geopackage') 1049 and not is_hypertable 1050 and constraint_exists 1051 ): 1052 constraint_name = sql_item_name(constraint_name_unquoted, self.flavor) 1053 constraint_or_index = ( 1054 "CONSTRAINT" 1055 if self.flavor not in ('mysql', 'mariadb') 1056 else 'INDEX' 1057 ) 1058 drop_queries[ix_key].append( 1059 f"ALTER TABLE {pipe_name}\n" 1060 f"DROP {constraint_or_index} {constraint_name}" 1061 ) 1062 1063 if not index_exists: 1064 continue 1065 1066 query = ( 1067 ( 1068 f"ALTER TABLE {pipe_name}\n" 1069 if self.flavor in ('mysql', 'mariadb') 1070 else '' 1071 ) 1072 + f"DROP INDEX {if_exists_str}" 1073 + sql_item_name(ix_unquoted, self.flavor, index_schema) 1074 ) 1075 if self.flavor == 'mssql': 1076 query += f"\nON {pipe_name}" 1077 if ix_unquoted == clustered_ix: 1078 query += "\nWITH (ONLINE = ON, MAXDOP = 4)" 1079 drop_queries[ix_key].append(query) 1080 1081 1082 return drop_queries
Return a dictionary mapping columns to a DROP INDEX or equivalent query.
Parameters
- pipe (mrsm.Pipe): The pipe to which the queries will correspond.
Returns
- A dictionary of column names mapping to lists of queries.
3481def get_add_columns_queries( 3482 self, 3483 pipe: mrsm.Pipe, 3484 df: Union[pd.DataFrame, Dict[str, str]], 3485 _is_db_types: bool = False, 3486 debug: bool = False, 3487) -> List[str]: 3488 """ 3489 Add new null columns of the correct type to a table from a dataframe. 3490 3491 Parameters 3492 ---------- 3493 pipe: mrsm.Pipe 3494 The pipe to be altered. 3495 3496 df: Union[pd.DataFrame, Dict[str, str]] 3497 The pandas DataFrame which contains new columns. 3498 If a dictionary is provided, assume it maps columns to Pandas data types. 3499 3500 _is_db_types: bool, default False 3501 If `True`, assume `df` is a dictionary mapping columns to SQL native dtypes. 3502 3503 Returns 3504 ------- 3505 A list of the `ALTER TABLE` SQL query or queries to be executed on the provided connector. 3506 """ 3507 if not pipe.exists(debug=debug): 3508 return [] 3509 3510 if pipe.parameters.get('static', False): 3511 return [] 3512 3513 from decimal import Decimal 3514 import copy 3515 from meerschaum.utils.sql import ( 3516 sql_item_name, 3517 SINGLE_ALTER_TABLE_FLAVORS, 3518 get_table_cols_types, 3519 ) 3520 from meerschaum.utils.dtypes.sql import ( 3521 get_pd_type_from_db_type, 3522 get_db_type_from_pd_type, 3523 ) 3524 from meerschaum.utils.misc import flatten_list 3525 is_dask = 'dask' in df.__module__ if not isinstance(df, dict) else False 3526 if is_dask: 3527 df = df.partitions[0].compute() 3528 df_cols_types = ( 3529 { 3530 col: str(typ) 3531 for col, typ in df.dtypes.items() 3532 } 3533 if not isinstance(df, dict) 3534 else copy.deepcopy(df) 3535 ) 3536 if not isinstance(df, dict) and len(df.index) > 0: 3537 for col, typ in list(df_cols_types.items()): 3538 if typ != 'object': 3539 continue 3540 val = df.iloc[0][col] 3541 if isinstance(val, (dict, list)): 3542 df_cols_types[col] = 'json' 3543 elif isinstance(val, Decimal): 3544 df_cols_types[col] = 'numeric' 3545 elif isinstance(val, str): 3546 df_cols_types[col] = 'str' 3547 db_cols_types = { 3548 col: get_pd_type_from_db_type(typ) 3549 for col, typ in get_table_cols_types( 3550 pipe.target, 3551 self, 3552 schema=self.get_pipe_schema(pipe), 3553 debug=debug, 3554 ).items() 3555 } 3556 new_cols = set(df_cols_types) - set(db_cols_types) 3557 if not new_cols: 3558 return [] 3559 3560 new_cols_types = { 3561 col: get_db_type_from_pd_type( 3562 df_cols_types[col], 3563 self.flavor 3564 ) 3565 for col in new_cols 3566 if col and df_cols_types.get(col, None) 3567 } 3568 3569 alter_table_query = "ALTER TABLE " + sql_item_name( 3570 pipe.target, self.flavor, self.get_pipe_schema(pipe) 3571 ) 3572 queries = [] 3573 for col, typ in new_cols_types.items(): 3574 add_col_query = ( 3575 "\nADD " 3576 + sql_item_name(col, self.flavor, None) 3577 + " " + typ + "," 3578 ) 3579 3580 if self.flavor in SINGLE_ALTER_TABLE_FLAVORS: 3581 queries.append(alter_table_query + add_col_query[:-1]) 3582 else: 3583 alter_table_query += add_col_query 3584 3585 ### For most flavors, only one query is required. 3586 ### This covers SQLite which requires one query per column. 3587 if not queries: 3588 queries.append(alter_table_query[:-1]) 3589 3590 if self.flavor != 'duckdb': 3591 return queries 3592 3593 ### NOTE: For DuckDB, we must drop and rebuild the indices. 3594 drop_index_queries = list(flatten_list( 3595 [q for ix, q in self.get_drop_index_queries(pipe, debug=debug).items()] 3596 )) 3597 create_index_queries = list(flatten_list( 3598 [q for ix, q in self.get_create_index_queries(pipe, debug=debug).items()] 3599 )) 3600 3601 return drop_index_queries + queries + create_index_queries
Add new null columns of the correct type to a table from a dataframe.
Parameters
- pipe (mrsm.Pipe): The pipe to be altered.
- df (Union[pd.DataFrame, Dict[str, str]]): The pandas DataFrame which contains new columns. If a dictionary is provided, assume it maps columns to Pandas data types.
- _is_db_types (bool, default False):
If
True, assumedfis a dictionary mapping columns to SQL native dtypes.
Returns
- A list of the
ALTER TABLESQL query or queries to be executed on the provided connector.
3604def get_alter_columns_queries( 3605 self, 3606 pipe: mrsm.Pipe, 3607 df: Union[pd.DataFrame, Dict[str, str]], 3608 debug: bool = False, 3609) -> List[str]: 3610 """ 3611 If we encounter a column of a different type, set the entire column to text. 3612 If the altered columns are numeric, alter to numeric instead. 3613 3614 Parameters 3615 ---------- 3616 pipe: mrsm.Pipe 3617 The pipe to be altered. 3618 3619 df: Union[pd.DataFrame, Dict[str, str]] 3620 The pandas DataFrame which may contain altered columns. 3621 If a dict is provided, assume it maps columns to Pandas data types. 3622 3623 Returns 3624 ------- 3625 A list of the `ALTER TABLE` SQL query or queries to be executed on the provided connector. 3626 """ 3627 if not pipe.exists(debug=debug) or pipe.static: 3628 return [] 3629 3630 from meerschaum.utils.sql import ( 3631 sql_item_name, 3632 get_table_cols_types, 3633 DROP_IF_EXISTS_FLAVORS, 3634 SINGLE_ALTER_TABLE_FLAVORS, 3635 ) 3636 from meerschaum.utils.dataframe import get_numeric_cols 3637 from meerschaum.utils.dtypes import are_dtypes_equal 3638 from meerschaum.utils.dtypes.sql import ( 3639 get_pd_type_from_db_type, 3640 get_db_type_from_pd_type, 3641 ) 3642 from meerschaum.utils.misc import flatten_list, generate_password, items_str 3643 target = pipe.target 3644 session_id = generate_password(3) 3645 numeric_cols = ( 3646 get_numeric_cols(df) 3647 if not isinstance(df, dict) 3648 else [ 3649 col 3650 for col, typ in df.items() 3651 if typ.startswith('numeric') 3652 ] 3653 ) 3654 df_cols_types = ( 3655 { 3656 col: str(typ) 3657 for col, typ in df.dtypes.items() 3658 } 3659 if not isinstance(df, dict) 3660 else df 3661 ) 3662 db_cols_types = { 3663 col: get_pd_type_from_db_type(typ) 3664 for col, typ in get_table_cols_types( 3665 pipe.target, 3666 self, 3667 schema=self.get_pipe_schema(pipe), 3668 debug=debug, 3669 ).items() 3670 } 3671 pipe_dtypes = pipe.get_dtypes(debug=debug) 3672 pipe_bool_cols = [col for col, typ in pipe_dtypes.items() if are_dtypes_equal(str(typ), 'bool')] 3673 pd_db_df_aliases = { 3674 'int': 'bool', 3675 'float': 'bool', 3676 'numeric': 'bool', 3677 'guid': 'object', 3678 } 3679 if self.flavor == 'oracle': 3680 pd_db_df_aliases.update({ 3681 'int': 'numeric', 3682 'date': 'datetime', 3683 'numeric': 'int', 3684 }) 3685 elif self.flavor == 'geopackage': 3686 pd_db_df_aliases.update({ 3687 'geometry': 'bytes', 3688 'bytes': 'geometry', 3689 }) 3690 3691 altered_cols = { 3692 col: (db_cols_types.get(col, 'object'), typ) 3693 for col, typ in df_cols_types.items() 3694 if not are_dtypes_equal(typ, db_cols_types.get(col, 'object').lower()) 3695 and not are_dtypes_equal(db_cols_types.get(col, 'object'), 'string') 3696 } 3697 3698 if debug and altered_cols: 3699 dprint("Columns to be altered:") 3700 mrsm.pprint(altered_cols) 3701 3702 ### NOTE: Special columns (numerics, bools, etc.) are captured and cached upon detection. 3703 new_special_cols = pipe._get_cached_value('new_special_cols', debug=debug) or {} 3704 new_special_db_cols_types = { 3705 col: (db_cols_types.get(col, 'object'), typ) 3706 for col, typ in new_special_cols.items() 3707 } 3708 if debug: 3709 dprint("Cached new special columns:") 3710 mrsm.pprint(new_special_cols) 3711 dprint("New special columns db types:") 3712 mrsm.pprint(new_special_db_cols_types) 3713 3714 altered_cols.update(new_special_db_cols_types) 3715 3716 ### NOTE: Sometimes bools are coerced into ints or floats. 3717 altered_cols_to_ignore = set() 3718 for col, (db_typ, df_typ) in altered_cols.items(): 3719 for db_alias, df_alias in pd_db_df_aliases.items(): 3720 if ( 3721 db_alias in db_typ.lower() 3722 and df_alias in df_typ.lower() 3723 and col not in new_special_cols 3724 ): 3725 altered_cols_to_ignore.add(col) 3726 3727 ### Oracle's bool handling sometimes mixes NUMBER and INT. 3728 for bool_col in pipe_bool_cols: 3729 if bool_col not in altered_cols: 3730 continue 3731 db_is_bool_compatible = ( 3732 are_dtypes_equal('int', altered_cols[bool_col][0]) 3733 or are_dtypes_equal('float', altered_cols[bool_col][0]) 3734 or are_dtypes_equal('numeric', altered_cols[bool_col][0]) 3735 or are_dtypes_equal('bool', altered_cols[bool_col][0]) 3736 ) 3737 df_is_bool_compatible = ( 3738 are_dtypes_equal('int', altered_cols[bool_col][1]) 3739 or are_dtypes_equal('float', altered_cols[bool_col][1]) 3740 or are_dtypes_equal('numeric', altered_cols[bool_col][1]) 3741 or are_dtypes_equal('bool', altered_cols[bool_col][1]) 3742 ) 3743 if db_is_bool_compatible and df_is_bool_compatible: 3744 altered_cols_to_ignore.add(bool_col) 3745 3746 if debug and altered_cols_to_ignore: 3747 dprint("Ignoring the following altered columns (false positives).") 3748 mrsm.pprint(altered_cols_to_ignore) 3749 3750 for col in altered_cols_to_ignore: 3751 _ = altered_cols.pop(col, None) 3752 3753 if not altered_cols: 3754 return [] 3755 3756 if numeric_cols: 3757 explicit_pipe_dtypes = pipe.get_dtypes(infer=False, debug=debug) 3758 explicit_pipe_dtypes.update({col: 'numeric' for col in numeric_cols}) 3759 pipe.dtypes = explicit_pipe_dtypes 3760 if not pipe.temporary: 3761 edit_success, edit_msg = pipe.edit(debug=debug) 3762 if not edit_success: 3763 warn( 3764 f"Failed to update dtypes for numeric columns {items_str(numeric_cols)}:\n" 3765 + f"{edit_msg}" 3766 ) 3767 else: 3768 numeric_cols.extend([col for col, typ in pipe_dtypes.items() if typ.startswith('numeric')]) 3769 3770 numeric_type = get_db_type_from_pd_type('numeric', self.flavor, as_sqlalchemy=False) 3771 text_type = get_db_type_from_pd_type('str', self.flavor, as_sqlalchemy=False) 3772 altered_cols_types = { 3773 col: ( 3774 numeric_type 3775 if col in numeric_cols 3776 else text_type 3777 ) 3778 for col, (db_typ, typ) in altered_cols.items() 3779 } 3780 3781 if self.flavor in ('sqlite', 'geopackage'): 3782 temp_table_name = '-' + session_id + '_' + target 3783 rename_query = ( 3784 "ALTER TABLE " 3785 + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe)) 3786 + " RENAME TO " 3787 + sql_item_name(temp_table_name, self.flavor, None) 3788 ) 3789 create_query = ( 3790 "CREATE TABLE " 3791 + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe)) 3792 + " (\n" 3793 ) 3794 for col_name, col_typ in db_cols_types.items(): 3795 create_query += ( 3796 sql_item_name(col_name, self.flavor, None) 3797 + " " 3798 + ( 3799 col_typ 3800 if col_name not in altered_cols 3801 else altered_cols_types[col_name] 3802 ) 3803 + ",\n" 3804 ) 3805 create_query = create_query[:-2] + "\n)" 3806 3807 insert_query = ( 3808 "INSERT INTO " 3809 + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe)) 3810 + ' (' 3811 + ', '.join([ 3812 sql_item_name(col_name, self.flavor, None) 3813 for col_name in db_cols_types 3814 ]) 3815 + ')' 3816 + "\nSELECT\n" 3817 ) 3818 for col_name in db_cols_types: 3819 new_col_str = ( 3820 sql_item_name(col_name, self.flavor, None) 3821 if col_name not in altered_cols 3822 else ( 3823 "CAST(" 3824 + sql_item_name(col_name, self.flavor, None) 3825 + " AS " 3826 + altered_cols_types[col_name] 3827 + ")" 3828 ) 3829 ) 3830 insert_query += new_col_str + ",\n" 3831 3832 insert_query = insert_query[:-2] + ( 3833 f"\nFROM {sql_item_name(temp_table_name, self.flavor, self.get_pipe_schema(pipe))}" 3834 ) 3835 3836 if_exists_str = "IF EXISTS" if self.flavor in DROP_IF_EXISTS_FLAVORS else "" 3837 3838 drop_query = f"DROP TABLE {if_exists_str}" + sql_item_name( 3839 temp_table_name, self.flavor, self.get_pipe_schema(pipe) 3840 ) 3841 return [ 3842 rename_query, 3843 create_query, 3844 insert_query, 3845 drop_query, 3846 ] 3847 3848 queries = [] 3849 if self.flavor == 'oracle': 3850 for col, typ in altered_cols_types.items(): 3851 add_query = ( 3852 "ALTER TABLE " 3853 + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe)) 3854 + "\nADD " + sql_item_name(col + '_temp', self.flavor, None) 3855 + " " + typ 3856 ) 3857 queries.append(add_query) 3858 3859 for col, typ in altered_cols_types.items(): 3860 populate_temp_query = ( 3861 "UPDATE " 3862 + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe)) 3863 + "\nSET " + sql_item_name(col + '_temp', self.flavor, None) 3864 + ' = ' + sql_item_name(col, self.flavor, None) 3865 ) 3866 queries.append(populate_temp_query) 3867 3868 for col, typ in altered_cols_types.items(): 3869 set_old_cols_to_null_query = ( 3870 "UPDATE " 3871 + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe)) 3872 + "\nSET " + sql_item_name(col, self.flavor, None) 3873 + ' = NULL' 3874 ) 3875 queries.append(set_old_cols_to_null_query) 3876 3877 for col, typ in altered_cols_types.items(): 3878 alter_type_query = ( 3879 "ALTER TABLE " 3880 + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe)) 3881 + "\nMODIFY " + sql_item_name(col, self.flavor, None) + ' ' 3882 + typ 3883 ) 3884 queries.append(alter_type_query) 3885 3886 for col, typ in altered_cols_types.items(): 3887 set_old_to_temp_query = ( 3888 "UPDATE " 3889 + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe)) 3890 + "\nSET " + sql_item_name(col, self.flavor, None) 3891 + ' = ' + sql_item_name(col + '_temp', self.flavor, None) 3892 ) 3893 queries.append(set_old_to_temp_query) 3894 3895 for col, typ in altered_cols_types.items(): 3896 drop_temp_query = ( 3897 "ALTER TABLE " 3898 + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe)) 3899 + "\nDROP COLUMN " + sql_item_name(col + '_temp', self.flavor, None) 3900 ) 3901 queries.append(drop_temp_query) 3902 3903 return queries 3904 3905 query = "ALTER TABLE " + sql_item_name(target, self.flavor, self.get_pipe_schema(pipe)) 3906 for col, typ in altered_cols_types.items(): 3907 alter_col_prefix = ( 3908 'ALTER' if self.flavor not in ('mysql', 'mariadb', 'oracle') 3909 else 'MODIFY' 3910 ) 3911 type_prefix = ( 3912 '' if self.flavor in ('mssql', 'mariadb', 'mysql') 3913 else 'TYPE ' 3914 ) 3915 column_str = 'COLUMN' if self.flavor != 'oracle' else '' 3916 query_suffix = ( 3917 f"\n{alter_col_prefix} {column_str} " 3918 + sql_item_name(col, self.flavor, None) 3919 + " " + type_prefix + typ + "," 3920 ) 3921 if self.flavor not in SINGLE_ALTER_TABLE_FLAVORS: 3922 query += query_suffix 3923 else: 3924 queries.append(query + query_suffix[:-1]) 3925 3926 if self.flavor not in SINGLE_ALTER_TABLE_FLAVORS: 3927 queries.append(query[:-1]) 3928 3929 if self.flavor != 'duckdb': 3930 return queries 3931 3932 drop_index_queries = list(flatten_list( 3933 [q for ix, q in self.get_drop_index_queries(pipe, debug=debug).items()] 3934 )) 3935 create_index_queries = list(flatten_list( 3936 [q for ix, q in self.get_create_index_queries(pipe, debug=debug).items()] 3937 )) 3938 3939 return drop_index_queries + queries + create_index_queries
If we encounter a column of a different type, set the entire column to text. If the altered columns are numeric, alter to numeric instead.
Parameters
- pipe (mrsm.Pipe): The pipe to be altered.
- df (Union[pd.DataFrame, Dict[str, str]]): The pandas DataFrame which may contain altered columns. If a dict is provided, assume it maps columns to Pandas data types.
Returns
- A list of the
ALTER TABLESQL query or queries to be executed on the provided connector.
1085def delete_pipe( 1086 self, 1087 pipe: mrsm.Pipe, 1088 debug: bool = False, 1089) -> SuccessTuple: 1090 """ 1091 Delete a Pipe's registration. 1092 """ 1093 from meerschaum.utils.packages import attempt_import 1094 sqlalchemy = attempt_import('sqlalchemy', lazy=False) 1095 1096 if not pipe.id: 1097 return False, f"{pipe} is not registered." 1098 1099 ### ensure pipes table exists 1100 from meerschaum.connectors.sql.tables import get_tables 1101 pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes'] 1102 1103 q = sqlalchemy.delete(pipes_tbl).where(pipes_tbl.c.pipe_id == pipe.id) 1104 if not self.exec(q, debug=debug): 1105 return False, f"Failed to delete registration for {pipe}." 1106 1107 return True, "Success"
Delete a Pipe's registration.
1110def get_pipe_data( 1111 self, 1112 pipe: mrsm.Pipe, 1113 select_columns: Optional[List[str]] = None, 1114 omit_columns: Optional[List[str]] = None, 1115 begin: Union[datetime, str, None] = None, 1116 end: Union[datetime, str, None] = None, 1117 params: Optional[Dict[str, Any]] = None, 1118 order: str = 'asc', 1119 limit: Optional[int] = None, 1120 begin_add_minutes: int = 0, 1121 end_add_minutes: int = 0, 1122 chunksize: Optional[int] = -1, 1123 as_iterator: bool = False, 1124 as_polars: bool = False, 1125 debug: bool = False, 1126 **kw: Any 1127) -> Union[pd.DataFrame, None]: 1128 """ 1129 Access a pipe's data from the SQL instance. 1130 1131 Parameters 1132 ---------- 1133 pipe: mrsm.Pipe: 1134 The pipe to get data from. 1135 1136 select_columns: Optional[List[str]], default None 1137 If provided, only select these given columns. 1138 Otherwise select all available columns (i.e. `SELECT *`). 1139 1140 omit_columns: Optional[List[str]], default None 1141 If provided, remove these columns from the selection. 1142 1143 begin: Union[datetime, str, None], default None 1144 If provided, get rows newer than or equal to this value. 1145 1146 end: Union[datetime, str, None], default None 1147 If provided, get rows older than or equal to this value. 1148 1149 params: Optional[Dict[str, Any]], default None 1150 Additional parameters to filter by. 1151 See `meerschaum.connectors.sql.build_where`. 1152 1153 order: Optional[str], default 'asc' 1154 The selection order for all of the indices in the query. 1155 If `None`, omit the `ORDER BY` clause. 1156 1157 limit: Optional[int], default None 1158 If specified, limit the number of rows retrieved to this value. 1159 1160 begin_add_minutes: int, default 0 1161 The number of minutes to add to the `begin` datetime (i.e. `DATEADD`). 1162 1163 end_add_minutes: int, default 0 1164 The number of minutes to add to the `end` datetime (i.e. `DATEADD`). 1165 1166 chunksize: Optional[int], default -1 1167 The size of dataframe chunks to load into memory. 1168 1169 as_iterator: bool, default False 1170 If `True`, return the chunks iterator directly. 1171 1172 as_polars: bool, default False 1173 If `True`, use an Arrow-native driver when available and return a Polars DataFrame. 1174 1175 debug: bool, default False 1176 Verbosity toggle. 1177 1178 Returns 1179 ------- 1180 A Pandas DataFrame, or a Polars DataFrame when an Arrow-native read is available. 1181 1182 """ 1183 from meerschaum.utils.packages import import_pandas 1184 from meerschaum.utils.dtypes import to_pandas_dtype, are_dtypes_equal 1185 from meerschaum.utils.dtypes.sql import get_pd_type_from_db_type 1186 pd = import_pandas() 1187 is_dask = 'dask' in pd.__name__ 1188 1189 cols_types = pipe.get_columns_types(debug=debug) if pipe.enforce else {} 1190 pipe_dtypes = pipe.get_dtypes(infer=False, debug=debug) if pipe.enforce else {} 1191 1192 remote_pandas_types = { 1193 col: to_pandas_dtype(get_pd_type_from_db_type(typ)) 1194 for col, typ in cols_types.items() 1195 } 1196 remote_dt_cols_types = { 1197 col: typ 1198 for col, typ in remote_pandas_types.items() 1199 if are_dtypes_equal(typ, 'datetime') 1200 } 1201 remote_dt_tz_aware_cols_types = { 1202 col: typ 1203 for col, typ in remote_dt_cols_types.items() 1204 if ',' in typ or typ == 'datetime' 1205 } 1206 remote_dt_tz_naive_cols_types = { 1207 col: typ 1208 for col, typ in remote_dt_cols_types.items() 1209 if col not in remote_dt_tz_aware_cols_types 1210 } 1211 1212 configured_pandas_types = { 1213 col: to_pandas_dtype(typ) 1214 for col, typ in pipe_dtypes.items() 1215 } 1216 configured_lower_precision_dt_cols_types = { 1217 col: typ 1218 for col, typ in pipe_dtypes.items() 1219 if ( 1220 are_dtypes_equal('datetime', typ) 1221 and '[' in typ 1222 and 'ns' not in typ 1223 ) 1224 1225 } 1226 1227 dtypes = { 1228 **remote_pandas_types, 1229 **configured_pandas_types, 1230 **remote_dt_tz_aware_cols_types, 1231 **remote_dt_tz_naive_cols_types, 1232 **configured_lower_precision_dt_cols_types 1233 } if pipe.enforce else {} 1234 1235 existing_cols = cols_types.keys() 1236 select_columns = ( 1237 [ 1238 col 1239 for col in existing_cols 1240 if col not in (omit_columns or []) 1241 ] 1242 if not select_columns 1243 else [ 1244 col 1245 for col in select_columns 1246 if col in existing_cols 1247 and col not in (omit_columns or []) 1248 ] 1249 ) if pipe.enforce else select_columns 1250 1251 if select_columns: 1252 dtypes = {col: typ for col, typ in dtypes.items() if col in select_columns} 1253 1254 dtypes = { 1255 col: typ 1256 for col, typ in dtypes.items() 1257 if col in (select_columns or [col]) and col not in (omit_columns or []) 1258 } if pipe.enforce else {} 1259 1260 if debug: 1261 dprint(f"[{self}] `read()` dtypes:") 1262 mrsm.pprint(dtypes) 1263 1264 query = self.get_pipe_data_query( 1265 pipe, 1266 select_columns=select_columns, 1267 omit_columns=omit_columns, 1268 begin=begin, 1269 end=end, 1270 params=params, 1271 order=order, 1272 limit=limit, 1273 begin_add_minutes=begin_add_minutes, 1274 end_add_minutes=end_add_minutes, 1275 debug=debug, 1276 **kw 1277 ) 1278 1279 arrow_dtypes = { 1280 col: pipe_dtypes.get(col, get_pd_type_from_db_type(cols_types.get(col, ''))) 1281 for col in dtypes 1282 } 1283 fallback_dtypes = { 1284 col: typ 1285 for col, typ in arrow_dtypes.items() 1286 if ( 1287 str(typ).lower() in ('uuid', 'object', 'numeric') 1288 or str(typ).lower().startswith(('geometry', 'geography')) 1289 ) 1290 } 1291 adbc_driver = { 1292 'sqlite': 'adbc_driver_sqlite', 1293 'postgresql': 'adbc_driver_postgresql', 1294 'postgis': 'adbc_driver_postgresql', 1295 'timescaledb': 'adbc_driver_postgresql', 1296 'timescaledb-ha': 'adbc_driver_postgresql', 1297 'citus': 'adbc_driver_postgresql', 1298 'cockroachdb': 'adbc_driver_postgresql', 1299 }.get(self.flavor, None) 1300 can_read_native = bool( 1301 as_polars 1302 and pipe.enforce 1303 and dtypes 1304 and not fallback_dtypes 1305 and not as_iterator 1306 and not is_dask 1307 and ( 1308 self.flavor == 'duckdb' 1309 or (adbc_driver and not (self.flavor == 'sqlite' and self.database == ':memory:')) 1310 ) 1311 ) 1312 if can_read_native: 1313 from meerschaum.utils.packages import attempt_import 1314 polars = attempt_import('polars', lazy=False) 1315 try: 1316 if self.flavor == 'duckdb': 1317 with self.engine.connect() as connection: 1318 return connection.connection.driver_connection.execute(query).pl() 1319 if attempt_import( 1320 adbc_driver, 1321 lazy=False, 1322 warn=False, 1323 install=False, 1324 ) is not None: 1325 import warnings 1326 adbc_uri = self.URI 1327 if adbc_driver == 'adbc_driver_postgresql': 1328 adbc_uri = 'postgresql://' + adbc_uri.split('://', maxsplit=1)[-1] 1329 with warnings.catch_warnings(): 1330 warnings.filterwarnings( 1331 'ignore', 1332 message=r"Extension type 'arrow\.(json|opaque)' is not registered", 1333 ) 1334 return polars.read_database_uri(query, adbc_uri, engine='adbc') 1335 except Exception as e: 1336 if debug: 1337 dprint(f"[{self}] Arrow-native read failed; falling back to Pandas:\n{e}") 1338 1339 read_kwargs = {} 1340 if is_dask: 1341 index_col = pipe.columns.get('datetime', None) 1342 read_kwargs['index_col'] = index_col 1343 1344 chunks = self.read( 1345 query, 1346 chunksize=chunksize, 1347 as_iterator=True, 1348 coerce_float=False, 1349 dtype=dtypes, 1350 debug=debug, 1351 **read_kwargs 1352 ) 1353 1354 if as_iterator: 1355 return chunks 1356 1357 return pd.concat(chunks)
Access a pipe's data from the SQL instance.
Parameters
- pipe (mrsm.Pipe:): The pipe to get data from.
- select_columns (Optional[List[str]], default None):
If provided, only select these given columns.
Otherwise select all available columns (i.e.
SELECT *). - omit_columns (Optional[List[str]], default None): If provided, remove these columns from the selection.
- begin (Union[datetime, str, None], default None): If provided, get rows newer than or equal to this value.
- end (Union[datetime, str, None], default None): If provided, get rows older than or equal to this value.
- params (Optional[Dict[str, Any]], default None):
Additional parameters to filter by.
See
meerschaum.connectors.sql.build_where. - order (Optional[str], default 'asc'):
The selection order for all of the indices in the query.
If
None, omit theORDER BYclause. - 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
begindatetime (i.e.DATEADD). - end_add_minutes (int, default 0):
The number of minutes to add to the
enddatetime (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.
1360def get_pipe_docs( 1361 self, 1362 pipe: mrsm.Pipe, 1363 select_columns: Optional[List[str]] = None, 1364 omit_columns: Optional[List[str]] = None, 1365 begin: Union[datetime, str, None] = None, 1366 end: Union[datetime, str, None] = None, 1367 params: Optional[Dict[str, Any]] = None, 1368 order: str = 'asc', 1369 limit: Optional[int] = None, 1370 debug: bool = False, 1371 **kw: Any 1372) -> List[Dict[str, Any]]: 1373 """ 1374 Return a pipe's data as a list of dictionaries, bypassing pandas overhead. 1375 """ 1376 query = self.get_pipe_data_query( 1377 pipe=pipe, 1378 select_columns=select_columns, 1379 omit_columns=omit_columns, 1380 begin=begin, 1381 end=end, 1382 params=params, 1383 order=order, 1384 limit=limit, 1385 debug=debug, 1386 ) 1387 if query is None: 1388 return [] 1389 result = self.exec(query, silent=True, debug=debug) 1390 if result is None: 1391 return [] 1392 return [dict(row) for row in result.mappings().fetchall()]
Return a pipe's data as a list of dictionaries, bypassing pandas overhead.
1395def get_pipe_data_query( 1396 self, 1397 pipe: mrsm.Pipe, 1398 select_columns: Optional[List[str]] = None, 1399 omit_columns: Optional[List[str]] = None, 1400 begin: Union[datetime, int, str, None] = None, 1401 end: Union[datetime, int, str, None] = None, 1402 params: Optional[Dict[str, Any]] = None, 1403 order: Optional[str] = 'asc', 1404 sort_datetimes: bool = False, 1405 limit: Optional[int] = None, 1406 begin_add_minutes: int = 0, 1407 end_add_minutes: int = 0, 1408 replace_nulls: Optional[str] = None, 1409 skip_existing_cols_check: bool = False, 1410 debug: bool = False, 1411 **kw: Any 1412) -> Union[str, None]: 1413 """ 1414 Return the `SELECT` query for retrieving a pipe's data from its instance. 1415 1416 Parameters 1417 ---------- 1418 pipe: mrsm.Pipe: 1419 The pipe to get data from. 1420 1421 select_columns: Optional[List[str]], default None 1422 If provided, only select these given columns. 1423 Otherwise select all available columns (i.e. `SELECT *`). 1424 1425 omit_columns: Optional[List[str]], default None 1426 If provided, remove these columns from the selection. 1427 1428 begin: Union[datetime, int, str, None], default None 1429 If provided, get rows newer than or equal to this value. 1430 1431 end: Union[datetime, str, None], default None 1432 If provided, get rows older than or equal to this value. 1433 1434 params: Optional[Dict[str, Any]], default None 1435 Additional parameters to filter by. 1436 See `meerschaum.connectors.sql.build_where`. 1437 1438 order: Optional[str], default None 1439 The selection order for all of the indices in the query. 1440 If `None`, omit the `ORDER BY` clause. 1441 1442 sort_datetimes: bool, default False 1443 Alias for `order='desc'`. 1444 1445 limit: Optional[int], default None 1446 If specified, limit the number of rows retrieved to this value. 1447 1448 begin_add_minutes: int, default 0 1449 The number of minutes to add to the `begin` datetime (i.e. `DATEADD`). 1450 1451 end_add_minutes: int, default 0 1452 The number of minutes to add to the `end` datetime (i.e. `DATEADD`). 1453 1454 chunksize: Optional[int], default -1 1455 The size of dataframe chunks to load into memory. 1456 1457 replace_nulls: Optional[str], default None 1458 If provided, replace null values with this value. 1459 1460 skip_existing_cols_check: bool, default False 1461 If `True`, do not verify that querying columns are actually on the table. 1462 1463 debug: bool, default False 1464 Verbosity toggle. 1465 1466 Returns 1467 ------- 1468 A `SELECT` query to retrieve a pipe's data. 1469 """ 1470 from meerschaum.utils.misc import items_str 1471 from meerschaum.utils.sql import sql_item_name, dateadd_str 1472 from meerschaum.utils.dtypes import coerce_timezone 1473 from meerschaum.utils.dtypes.sql import get_pd_type_from_db_type, get_db_type_from_pd_type 1474 1475 dt_col = pipe.columns.get('datetime', None) 1476 existing_cols = pipe.get_columns_types(debug=debug) if pipe.enforce else [] 1477 skip_existing_cols_check = skip_existing_cols_check or not pipe.enforce 1478 dt_typ = get_pd_type_from_db_type(existing_cols[dt_col]) if dt_col in existing_cols else None 1479 dt_db_type = get_db_type_from_pd_type(dt_typ, self.flavor) if dt_typ else None 1480 select_columns = ( 1481 [col for col in existing_cols] 1482 if not select_columns 1483 else [col for col in select_columns if skip_existing_cols_check or col in existing_cols] 1484 ) 1485 if omit_columns: 1486 select_columns = [col for col in select_columns if col not in omit_columns] 1487 1488 if order is None and sort_datetimes: 1489 order = 'desc' 1490 1491 if begin == '': 1492 begin = pipe.get_sync_time(debug=debug) 1493 backtrack_interval = pipe.get_backtrack_interval(debug=debug) 1494 if begin is not None: 1495 begin -= backtrack_interval 1496 1497 begin, end = pipe.parse_date_bounds(begin, end) 1498 if isinstance(begin, datetime) and dt_typ: 1499 begin = coerce_timezone(begin, strip_utc=('utc' not in dt_typ.lower())) 1500 if isinstance(end, datetime) and dt_typ: 1501 end = coerce_timezone(end, strip_utc=('utc' not in dt_typ.lower())) 1502 1503 cols_names = [ 1504 sql_item_name(col, self.flavor, None) 1505 for col in select_columns 1506 ] 1507 select_cols_str = ( 1508 'SELECT\n ' 1509 + ',\n '.join( 1510 [ 1511 ( 1512 col_name 1513 if not replace_nulls 1514 else f"COALESCE(col_name, '{replace_nulls}') AS {col_name}" 1515 ) 1516 for col_name in cols_names 1517 ] 1518 ) 1519 ) if cols_names else 'SELECT *' 1520 pipe_table_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe)) 1521 query = f"{select_cols_str}\nFROM {pipe_table_name}" 1522 where = "" 1523 1524 ### MariaDB 12.x optimizer bug: an ordered index scan with `LIMIT` over a `RANGE COLUMNS`- 1525 ### partitioned table that must read a non-indexed column returns zero rows (see the 1526 ### `get_sync_time` workaround in CLAUDE.md — same family, MariaDB-only). Wrapping the 1527 ### datetime column in the `ORDER BY` with a no-op `COALESCE(col, col)` forces a filesort 1528 ### over the fetched rows instead of the broken index walk, with identical ordering. Gated 1529 ### on `LIMIT` (the bug's trigger) so unlimited ordered reads keep the index-ordered scan. 1530 mariadb_partition_order_workaround = ( 1531 self.flavor == 'mariadb' 1532 and isinstance(limit, int) 1533 and self._should_partition(pipe) 1534 ) 1535 1536 if order is not None: 1537 default_order = 'asc' 1538 if order not in ('asc', 'desc'): 1539 warn(f"Ignoring unsupported order '{order}'. Falling back to '{default_order}'.") 1540 order = default_order 1541 order = order.upper() 1542 1543 if not pipe.columns.get('datetime', None): 1544 _dt = pipe.guess_datetime() 1545 dt = sql_item_name(_dt, self.flavor, None) if _dt else None 1546 is_guess = True 1547 else: 1548 _dt = pipe.get_columns('datetime') 1549 dt = sql_item_name(_dt, self.flavor, None) 1550 is_guess = False 1551 1552 quoted_indices = { 1553 key: sql_item_name(val, self.flavor, None) 1554 for key, val in pipe.columns.items() 1555 if val in existing_cols or skip_existing_cols_check 1556 } 1557 1558 if begin is not None or end is not None: 1559 if is_guess: 1560 if _dt is None: 1561 warn( 1562 f"No datetime could be determined for {pipe}." 1563 + "\n Ignoring begin and end...", 1564 stack=False, 1565 ) 1566 begin, end = None, None 1567 else: 1568 warn( 1569 f"A datetime wasn't specified for {pipe}.\n" 1570 + f" Using column \"{_dt}\" for datetime bounds...", 1571 stack=False, 1572 ) 1573 1574 is_dt_bound = False 1575 if begin is not None and (_dt in existing_cols or skip_existing_cols_check): 1576 begin_da = dateadd_str( 1577 flavor=self.flavor, 1578 datepart='minute', 1579 number=begin_add_minutes, 1580 begin=begin, 1581 db_type=dt_db_type, 1582 ) 1583 where += f"\n {dt} >= {begin_da}" + ("\n AND\n " if end is not None else "") 1584 is_dt_bound = True 1585 1586 if end is not None and (_dt in existing_cols or skip_existing_cols_check): 1587 if 'int' in str(type(end)).lower() and end == begin: 1588 end += 1 1589 end_da = dateadd_str( 1590 flavor=self.flavor, 1591 datepart='minute', 1592 number=end_add_minutes, 1593 begin=end, 1594 db_type=dt_db_type, 1595 ) 1596 where += f"{dt} < {end_da}" 1597 is_dt_bound = True 1598 1599 if params is not None: 1600 from meerschaum.utils.sql import build_where 1601 valid_params = { 1602 k: v 1603 for k, v in params.items() 1604 if k in existing_cols or skip_existing_cols_check 1605 } 1606 if valid_params: 1607 where += ' ' + build_where(valid_params, self).lstrip().replace( 1608 'WHERE', (' AND' if is_dt_bound else " ") 1609 ) 1610 1611 if len(where) > 0: 1612 query += "\nWHERE " + where 1613 1614 if order is not None: 1615 ### Sort by indices, starting with datetime. 1616 order_by = "" 1617 if quoted_indices: 1618 order_by += "\nORDER BY " 1619 if _dt and (_dt in existing_cols or skip_existing_cols_check): 1620 dt_order_expr = ( 1621 f"COALESCE({dt}, {dt})" 1622 if mariadb_partition_order_workaround 1623 else dt 1624 ) 1625 order_by += dt_order_expr + ' ' + order + ',' 1626 for key, quoted_col_name in quoted_indices.items(): 1627 if dt == quoted_col_name: 1628 continue 1629 order_by += ' ' + quoted_col_name + ' ' + order + ',' 1630 order_by = order_by[:-1] 1631 1632 query += order_by 1633 1634 if isinstance(limit, int): 1635 if self.flavor == 'mssql': 1636 query = f'SELECT TOP {limit}\n' + query[len("SELECT "):] 1637 elif self.flavor == 'oracle': 1638 query = ( 1639 f"SELECT * FROM (\n {query}\n)\n" 1640 + f"WHERE ROWNUM IN ({', '.join([str(i) for i in range(1, limit+1)])})" 1641 ) 1642 else: 1643 query += f"\nLIMIT {limit}" 1644 1645 if debug: 1646 to_print = ( 1647 [] 1648 + ([f"begin='{begin}'"] if begin else []) 1649 + ([f"end='{end}'"] if end else []) 1650 + ([f"params={params}"] if params else []) 1651 ) 1652 dprint("Getting pipe data with constraints: " + items_str(to_print, quotes=False)) 1653 1654 return query
Return the SELECT query for retrieving a pipe's data from its instance.
Parameters
- pipe (mrsm.Pipe:): The pipe to get data from.
- select_columns (Optional[List[str]], default None):
If provided, only select these given columns.
Otherwise select all available columns (i.e.
SELECT *). - omit_columns (Optional[List[str]], default None): If provided, remove these columns from the selection.
- begin (Union[datetime, int, str, None], default None): If provided, get rows newer than or equal to this value.
- end (Union[datetime, str, None], default None): If provided, get rows older than or equal to this value.
- params (Optional[Dict[str, Any]], default None):
Additional parameters to filter by.
See
meerschaum.connectors.sql.build_where. - order (Optional[str], default None):
The selection order for all of the indices in the query.
If
None, omit theORDER BYclause. - 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
begindatetime (i.e.DATEADD). - end_add_minutes (int, default 0):
The number of minutes to add to the
enddatetime (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
SELECTquery to retrieve a pipe's data.
26def register_pipe( 27 self, 28 pipe: mrsm.Pipe, 29 debug: bool = False, 30) -> SuccessTuple: 31 """ 32 Register a new pipe. 33 A pipe's attributes must be set before registering. 34 """ 35 from meerschaum.utils.packages import attempt_import 36 from meerschaum.utils.sql import json_flavors 37 38 ### ensure pipes table exists 39 from meerschaum.connectors.sql.tables import get_tables 40 pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes'] 41 42 if pipe.id is not None: 43 return False, f"{pipe} is already registered." 44 45 ### NOTE: if `parameters` is supplied in the Pipe constructor, 46 ### then `pipe.parameters` will exist and not be fetched from the database. 47 48 ### 1. Prioritize the Pipe object's `parameters` first. 49 ### E.g. if the user manually sets the `parameters` property 50 ### or if the Pipe already exists 51 ### (which shouldn't be able to be registered anyway but that's an issue for later). 52 parameters = None 53 try: 54 parameters = pipe.get_parameters(apply_symlinks=False) 55 except Exception as e: 56 if debug: 57 dprint(str(e)) 58 parameters = None 59 60 ### ensure `parameters` is a dictionary 61 if parameters is None: 62 parameters = {} 63 64 import json 65 sqlalchemy = attempt_import('sqlalchemy', lazy=False) 66 values = { 67 'connector_keys' : pipe.connector_keys, 68 'metric_key' : pipe.metric_key, 69 'location_key' : pipe.location_key, 70 'parameters' : ( 71 json.dumps(parameters) 72 if self.flavor not in json_flavors 73 else parameters 74 ), 75 } 76 query = sqlalchemy.insert(pipes_tbl).values(**values) 77 result = self.exec(query, debug=debug) 78 if result is None: 79 return False, f"Failed to register {pipe}." 80 return True, f"Successfully registered {pipe}."
Register a new pipe. A pipe's attributes must be set before registering.
83def edit_pipe( 84 self, 85 pipe: mrsm.Pipe, 86 patch: bool = False, 87 debug: bool = False, 88 **kw : Any 89) -> SuccessTuple: 90 """ 91 Persist a Pipe's parameters to its database. 92 93 Parameters 94 ---------- 95 pipe: mrsm.Pipe, default None 96 The pipe to be edited. 97 patch: bool, default False 98 If patch is `True`, update the existing parameters by cascading. 99 Otherwise overwrite the parameters (default). 100 debug: bool, default False 101 Verbosity toggle. 102 """ 103 104 if pipe.id is None: 105 return False, f"{pipe} is not registered and cannot be edited." 106 107 from meerschaum.utils.packages import attempt_import 108 from meerschaum.utils.sql import json_flavors 109 if not patch: 110 parameters = pipe.__dict__.get('_attributes', {}).get('parameters', {}) 111 else: 112 from meerschaum import Pipe 113 from meerschaum.config._patch import apply_patch_to_config 114 original_parameters = Pipe( 115 pipe.connector_keys, pipe.metric_key, pipe.location_key, 116 mrsm_instance=pipe.instance_keys 117 ).get_parameters(apply_symlinks=False) 118 parameters = apply_patch_to_config( 119 original_parameters, 120 pipe._attributes['parameters'] 121 ) 122 123 ### ensure pipes table exists 124 from meerschaum.connectors.sql.tables import get_tables 125 pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes'] 126 127 import json 128 sqlalchemy = attempt_import('sqlalchemy', lazy=False) 129 130 values = { 131 'parameters': ( 132 json.dumps(parameters) 133 if self.flavor not in json_flavors 134 else parameters 135 ), 136 } 137 q = sqlalchemy.update(pipes_tbl).values(**values).where( 138 pipes_tbl.c.pipe_id == pipe.id 139 ) 140 141 result = self.exec(q, debug=debug) 142 message = ( 143 f"Successfully edited {pipe}." 144 if result is not None else f"Failed to edit {pipe}." 145 ) 146 return (result is not None), message
Persist a Pipe's parameters to its database.
Parameters
- pipe (mrsm.Pipe, default None): The pipe to be edited.
- patch (bool, default False):
If patch is
True, update the existing parameters by cascading. Otherwise overwrite the parameters (default). - debug (bool, default False): Verbosity toggle.
1657def get_pipe_id( 1658 self, 1659 pipe: mrsm.Pipe, 1660 debug: bool = False, 1661) -> Any: 1662 """ 1663 Get a Pipe's ID from the pipes table. 1664 """ 1665 if pipe.temporary: 1666 return None 1667 from meerschaum.utils.packages import attempt_import 1668 sqlalchemy = attempt_import('sqlalchemy') 1669 from meerschaum.connectors.sql.tables import get_tables 1670 pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes'] 1671 1672 query = sqlalchemy.select(pipes_tbl.c.pipe_id).where( 1673 pipes_tbl.c.connector_keys == pipe.connector_keys 1674 ).where( 1675 pipes_tbl.c.metric_key == pipe.metric_key 1676 ).where( 1677 (pipes_tbl.c.location_key == pipe.location_key) if pipe.location_key is not None 1678 else pipes_tbl.c.location_key.is_(None) 1679 ) 1680 _id = self.value(query, debug=debug, silent=pipe.temporary) 1681 if _id is not None: 1682 _id = int(_id) 1683 return _id
Get a Pipe's ID from the pipes table.
1686def get_pipe_attributes( 1687 self, 1688 pipe: mrsm.Pipe, 1689 debug: bool = False, 1690) -> Dict[str, Any]: 1691 """ 1692 Get a Pipe's attributes dictionary. 1693 """ 1694 from meerschaum.connectors.sql.tables import get_tables 1695 from meerschaum.utils.packages import attempt_import 1696 sqlalchemy = attempt_import('sqlalchemy', lazy=False) 1697 1698 if pipe.id is None: 1699 return {} 1700 1701 pipes_tbl = get_tables(mrsm_instance=self, create=(not pipe.temporary), debug=debug)['pipes'] 1702 1703 q = sqlalchemy.select(pipes_tbl).where(pipes_tbl.c.pipe_id == pipe.id) 1704 if debug: 1705 dprint(q) 1706 result = self.exec(q, debug=debug) if self.flavor != 'duckdb' else self.read(q, debug=debug) 1707 if result is None: 1708 raise RuntimeError(f"Failed to read metadata for {pipe} from {self}.") 1709 rows = ( 1710 result.mappings().all() 1711 if self.flavor != 'duckdb' 1712 else result.to_dict(orient='records') 1713 ) 1714 if not rows: 1715 return {} 1716 attributes = dict(rows[0]) 1717 1718 ### handle non-PostgreSQL databases (text vs JSON) 1719 if not isinstance(attributes.get('parameters', None), dict): 1720 try: 1721 import json 1722 parameters = json.loads(attributes['parameters']) 1723 if isinstance(parameters, str) and parameters[0] == '{': 1724 parameters = json.loads(parameters) 1725 attributes['parameters'] = parameters 1726 except Exception: 1727 attributes['parameters'] = {} 1728 1729 return attributes
Get a Pipe's attributes dictionary.
1904def sync_pipe( 1905 self, 1906 pipe: mrsm.Pipe, 1907 df: Union[pd.DataFrame, str, Dict[Any, Any], None] = None, 1908 begin: Union[datetime, int, None] = None, 1909 end: Union[datetime, int, None] = None, 1910 chunksize: Optional[int] = -1, 1911 check_existing: bool = True, 1912 blocking: bool = True, 1913 debug: bool = False, 1914 _check_temporary_tables: bool = True, 1915 **kw: Any 1916) -> SuccessTuple: 1917 """ 1918 Sync a pipe using a database connection. 1919 1920 Parameters 1921 ---------- 1922 pipe: mrsm.Pipe 1923 The Meerschaum Pipe instance into which to sync the data. 1924 1925 df: Union[pandas.DataFrame, str, Dict[Any, Any], List[Dict[str, Any]]] 1926 An optional DataFrame or equivalent to sync into the pipe. 1927 Defaults to `None`. 1928 1929 begin: Union[datetime, int, None], default None 1930 Optionally specify the earliest datetime to search for data. 1931 Defaults to `None`. 1932 1933 end: Union[datetime, int, None], default None 1934 Optionally specify the latest datetime to search for data. 1935 Defaults to `None`. 1936 1937 chunksize: Optional[int], default -1 1938 Specify the number of rows to sync per chunk. 1939 If `-1`, resort to system configuration (default is `900`). 1940 A `chunksize` of `None` will sync all rows in one transaction. 1941 Defaults to `-1`. 1942 1943 check_existing: bool, default True 1944 If `True`, pull and diff with existing data from the pipe. Defaults to `True`. 1945 1946 blocking: bool, default True 1947 If `True`, wait for sync to finish and return its result, otherwise asyncronously sync. 1948 Defaults to `True`. 1949 1950 debug: bool, default False 1951 Verbosity toggle. Defaults to False. 1952 1953 kw: Any 1954 Catch-all for keyword arguments. 1955 1956 Returns 1957 ------- 1958 A `SuccessTuple` of success (`bool`) and message (`str`). 1959 """ 1960 from meerschaum.utils.packages import import_pandas 1961 from meerschaum.utils.sql import ( 1962 get_update_queries, 1963 sql_item_name, 1964 UPDATE_QUERIES, 1965 get_reset_autoincrement_queries, 1966 ) 1967 from meerschaum.utils.dtypes import get_current_timestamp 1968 from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type 1969 from meerschaum.utils.dataframe import get_special_cols 1970 from meerschaum import Pipe 1971 import time 1972 import copy 1973 pd = import_pandas() 1974 if df is None: 1975 msg = f"DataFrame is None. Cannot sync {pipe}." 1976 warn(msg) 1977 return False, msg 1978 1979 start = time.perf_counter() 1980 pipe_name = sql_item_name(pipe.target, self.flavor, schema=self.get_pipe_schema(pipe)) 1981 dtypes = pipe.get_dtypes(debug=debug) 1982 1983 if not pipe.temporary and not pipe.id: 1984 register_tuple = pipe.register(debug=debug) 1985 if not register_tuple[0]: 1986 return register_tuple 1987 1988 ### df is the dataframe returned from the remote source 1989 ### via the connector 1990 if debug: 1991 dprint("Fetched data:\n" + str(df)) 1992 1993 if not isinstance(df, pd.DataFrame): 1994 df = pipe.enforce_dtypes( 1995 df, 1996 chunksize=chunksize, 1997 safe_copy=kw.get('safe_copy', False), 1998 dtypes=dtypes, 1999 debug=debug, 2000 ) 2001 2002 ### if table does not exist, create it with indices 2003 is_new = False 2004 if not pipe.exists(debug=debug): 2005 check_existing = False 2006 is_new = True 2007 else: 2008 ### Check for new columns. 2009 add_cols_queries = self.get_add_columns_queries(pipe, df, debug=debug) 2010 if add_cols_queries: 2011 pipe._clear_cache_key('_columns_types', debug=debug) 2012 pipe._clear_cache_key('_columns_indices', debug=debug) 2013 if not self.exec_queries(add_cols_queries, debug=debug): 2014 warn(f"Failed to add new columns to {pipe}.") 2015 2016 alter_cols_queries = self.get_alter_columns_queries(pipe, df, debug=debug) 2017 if alter_cols_queries: 2018 pipe._clear_cache_key('_columns_types', debug=debug) 2019 pipe._clear_cache_key('_columns_types', debug=debug) 2020 if not self.exec_queries(alter_cols_queries, debug=debug): 2021 warn(f"Failed to alter columns for {pipe}.") 2022 2023 upsert = pipe.parameters.get('upsert', False) and (self.flavor + '-upsert') in UPDATE_QUERIES 2024 if upsert: 2025 check_existing = False 2026 kw['safe_copy'] = kw.get('safe_copy', False) 2027 2028 unseen_df, update_df, delta_df = ( 2029 pipe.filter_existing( 2030 df, 2031 chunksize=chunksize, 2032 debug=debug, 2033 **kw 2034 ) if check_existing else (df, None, df) 2035 ) 2036 if upsert: 2037 unseen_df, update_df, delta_df = (df.head(0), df, df) 2038 2039 if debug: 2040 dprint("Delta data:\n" + str(delta_df)) 2041 dprint("Unseen data:\n" + str(unseen_df)) 2042 if update_df is not None: 2043 dprint(("Update" if not upsert else "Upsert") + " data:\n" + str(update_df)) 2044 2045 if_exists = kw.get('if_exists', 'append') 2046 if 'if_exists' in kw: 2047 kw.pop('if_exists') 2048 if 'name' in kw: 2049 kw.pop('name') 2050 2051 ### Insert new data into the target table. 2052 unseen_kw = copy.deepcopy(kw) 2053 unseen_kw.update({ 2054 'name': pipe.target, 2055 'if_exists': if_exists, 2056 'debug': debug, 2057 'as_dict': True, 2058 'safe_copy': kw.get('safe_copy', False), 2059 'chunksize': chunksize, 2060 'dtype': self.get_to_sql_dtype(pipe, unseen_df, update_dtypes=True), 2061 'schema': self.get_pipe_schema(pipe), 2062 }) 2063 2064 dt_col = pipe.columns.get('datetime', None) 2065 primary_key = pipe.columns.get('primary', None) 2066 autoincrement = ( 2067 pipe.parameters.get('autoincrement', False) 2068 or ( 2069 is_new 2070 and primary_key 2071 and primary_key 2072 not in dtypes 2073 and primary_key not in unseen_df.columns 2074 ) 2075 ) 2076 if autoincrement and autoincrement not in pipe.parameters: 2077 update_success, update_msg = pipe.update_parameters( 2078 {'autoincrement': autoincrement}, 2079 debug=debug, 2080 ) 2081 if not update_success: 2082 return update_success, update_msg 2083 2084 def _check_pk(_df_to_clear): 2085 if _df_to_clear is None: 2086 return 2087 if primary_key not in _df_to_clear.columns: 2088 return 2089 if not _df_to_clear[primary_key].notnull().any(): 2090 del _df_to_clear[primary_key] 2091 2092 autoincrement_needs_reset = bool( 2093 autoincrement 2094 and primary_key 2095 and primary_key in unseen_df.columns 2096 and unseen_df[primary_key].notnull().any() 2097 ) 2098 if autoincrement and primary_key: 2099 for _df_to_clear in (unseen_df, update_df, delta_df): 2100 _check_pk(_df_to_clear) 2101 2102 if is_new: 2103 create_success, create_msg = self.create_pipe_table_from_df( 2104 pipe, 2105 unseen_df, 2106 debug=debug, 2107 ) 2108 if not create_success: 2109 return create_success, create_msg 2110 2111 ### Pre-create native range partitions (non-TimescaleDB) so the rows about to be written 2112 ### land in an existing partition. No-op for non-partitioned pipes. 2113 if self._should_partition(pipe): 2114 for _part_df in (unseen_df, update_df): 2115 if _part_df is not None and len(_part_df) > 0: 2116 part_success, part_msg = self._create_missing_partitions( 2117 pipe, _part_df, debug=debug, 2118 ) 2119 if not part_success: 2120 return part_success, part_msg 2121 2122 do_identity_insert = bool( 2123 self.flavor in ('mssql',) 2124 and primary_key 2125 and primary_key in unseen_df.columns 2126 and autoincrement 2127 ) 2128 stats = {'success': True, 'msg': ''} 2129 if len(unseen_df) > 0: 2130 with self.engine.connect() as connection: 2131 with connection.begin(): 2132 if do_identity_insert: 2133 identity_on_result = self.exec( 2134 f"SET IDENTITY_INSERT {pipe_name} ON", 2135 commit=False, 2136 _connection=connection, 2137 close=False, 2138 debug=debug, 2139 ) 2140 if identity_on_result is None: 2141 return False, f"Could not enable identity inserts on {pipe}." 2142 2143 stats = self.to_sql( 2144 unseen_df, 2145 _connection=connection, 2146 **unseen_kw 2147 ) 2148 2149 if do_identity_insert: 2150 identity_off_result = self.exec( 2151 f"SET IDENTITY_INSERT {pipe_name} OFF", 2152 commit=False, 2153 _connection=connection, 2154 close=False, 2155 debug=debug, 2156 ) 2157 if identity_off_result is None: 2158 return False, f"Could not disable identity inserts on {pipe}." 2159 2160 if is_new: 2161 if not self.create_indices(pipe, debug=debug): 2162 warn(f"Failed to create indices for {pipe}. Continuing...") 2163 2164 if autoincrement_needs_reset: 2165 reset_autoincrement_queries = get_reset_autoincrement_queries( 2166 pipe.target, 2167 primary_key, 2168 self, 2169 schema=self.get_pipe_schema(pipe), 2170 debug=debug, 2171 ) 2172 results = self.exec_queries(reset_autoincrement_queries, debug=debug) 2173 for result in results: 2174 if result is None: 2175 warn(f"Could not reset auto-incrementing primary key for {pipe}.", stack=False) 2176 2177 if update_df is not None and len(update_df) > 0: 2178 temp_target = self.get_temporary_target( 2179 pipe.target, 2180 label=('update' if not upsert else 'upsert'), 2181 ) 2182 self._log_temporary_tables_creation(temp_target, create=(not pipe.temporary), debug=debug) 2183 pipe_dtypes = pipe.dtypes 2184 update_dtypes = { 2185 **{ 2186 col: str(typ) 2187 for col, typ in update_df.dtypes.items() 2188 }, 2189 **get_special_cols(update_df), 2190 **{ 2191 col: pipe_dtypes[col] 2192 for col in update_df.columns 2193 if col in pipe_dtypes and update_df[col].isnull().all() 2194 }, 2195 } 2196 2197 temp_pipe = Pipe( 2198 pipe.connector_keys.replace(':', '_') + '_', pipe.metric_key, pipe.location_key, 2199 instance=pipe.instance_keys, 2200 columns={ 2201 (ix_key if ix_key != 'primary' else 'primary_'): ix 2202 for ix_key, ix in pipe.columns.items() 2203 if ix and ix in update_df.columns 2204 }, 2205 dtypes=update_dtypes, 2206 target=temp_target, 2207 temporary=True, 2208 enforce=False, 2209 static=True, 2210 autoincrement=False, 2211 cache=False, 2212 parameters={ 2213 'schema': self.internal_schema, 2214 'hypertable': False, 2215 }, 2216 ) 2217 _temp_columns_types = { 2218 col: get_db_type_from_pd_type(typ, self.flavor) 2219 for col, typ in update_dtypes.items() 2220 } 2221 temp_pipe._cache_value('_columns_types', _temp_columns_types, memory_only=True, debug=debug) 2222 temp_pipe._cache_value('_skip_check_indices', True, memory_only=True, debug=debug) 2223 now_ts = get_current_timestamp('ms', as_int=True) / 1000 2224 temp_pipe._cache_value('_columns_types_timestamp', now_ts, memory_only=True, debug=debug) 2225 temp_success, temp_msg = temp_pipe.sync(update_df, check_existing=False, debug=debug) 2226 if not temp_success: 2227 return temp_success, temp_msg 2228 2229 existing_cols = pipe.get_columns_types(debug=debug) 2230 ### A partitioned table (TimescaleDB hypertable or a native range-partitioned table on 2231 ### PostgreSQL/MySQL/MSSQL) folds the datetime column into its composite primary key, so the 2232 ### upsert conflict target must include it too — `ON CONFLICT (primary_key)` alone has no 2233 ### matching unique constraint. Non-partitioned tables keep the historical primary-key-only 2234 ### semantics ("the primary key is the identity, regardless of datetime"). 2235 partition_upsert = bool( 2236 dt_col 2237 and dt_col in update_df.columns 2238 and ( 2239 self.flavor in ('timescaledb', 'timescaledb-ha') 2240 or self._should_partition(pipe) 2241 ) 2242 ) 2243 join_cols = [ 2244 col 2245 for col_key, col in pipe.columns.items() 2246 if col and col in existing_cols 2247 ] if not primary_key or self.flavor == 'oracle' else ( 2248 [dt_col, primary_key] 2249 if partition_upsert 2250 else [primary_key] 2251 ) 2252 update_queries = get_update_queries( 2253 pipe.target, 2254 temp_target, 2255 self, 2256 join_cols, 2257 upsert=upsert, 2258 schema=self.get_pipe_schema(pipe), 2259 patch_schema=self.internal_schema, 2260 target_cols_types=pipe.get_columns_types(debug=debug), 2261 patch_cols_types=_temp_columns_types, 2262 datetime_col=(dt_col if dt_col in update_df.columns else None), 2263 identity_insert=(autoincrement and primary_key in update_df.columns), 2264 null_indices=pipe.null_indices, 2265 cast_columns=pipe.enforce, 2266 debug=debug, 2267 ) 2268 update_results = self.exec_queries( 2269 update_queries, 2270 break_on_error=True, 2271 rollback=True, 2272 debug=debug, 2273 ) 2274 update_success = all(update_results) 2275 self._log_temporary_tables_creation( 2276 temp_target, 2277 ready_to_drop=True, 2278 create=(not pipe.temporary), 2279 debug=debug, 2280 ) 2281 if not update_success: 2282 warn(f"Failed to apply update to {pipe}.") 2283 stats['success'] = stats['success'] and update_success 2284 stats['msg'] = ( 2285 (stats.get('msg', '') + f'\nFailed to apply update to {pipe}.').lstrip() 2286 if not update_success 2287 else stats.get('msg', '') 2288 ) 2289 2290 stop = time.perf_counter() 2291 success = stats['success'] 2292 if not success: 2293 return success, stats['msg'] or str(stats) 2294 2295 unseen_count = len(unseen_df.index) if unseen_df is not None else 0 2296 update_count = len(update_df.index) if update_df is not None else 0 2297 msg = ( 2298 ( 2299 f"Inserted {unseen_count:,}, " 2300 + f"updated {update_count:,} rows." 2301 ) 2302 if not upsert 2303 else ( 2304 f"Upserted {update_count:,} row" 2305 + ('s' if update_count != 1 else '') 2306 + "." 2307 ) 2308 ) 2309 if debug: 2310 msg = msg[:-1] + ( 2311 f"\non table {sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe))}\n" 2312 + f"in {round(stop - start, 2)} seconds." 2313 ) 2314 2315 if _check_temporary_tables: 2316 drop_stale_success, drop_stale_msg = self._drop_old_temporary_tables( 2317 refresh=False, debug=debug 2318 ) 2319 if not drop_stale_success: 2320 warn(drop_stale_msg) 2321 2322 return success, msg
Sync a pipe using a database connection.
Parameters
- pipe (mrsm.Pipe): The Meerschaum Pipe instance into which to sync the data.
- df (Union[pandas.DataFrame, str, Dict[Any, Any], List[Dict[str, Any]]]):
An optional DataFrame or equivalent to sync into the pipe.
Defaults to
None. - begin (Union[datetime, int, None], default None):
Optionally specify the earliest datetime to search for data.
Defaults to
None. - end (Union[datetime, int, None], default None):
Optionally specify the latest datetime to search for data.
Defaults to
None. - chunksize (Optional[int], default -1):
Specify the number of rows to sync per chunk.
If
-1, resort to system configuration (default is900). AchunksizeofNonewill 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 toTrue. - blocking (bool, default True):
If
True, wait for sync to finish and return its result, otherwise asyncronously sync. Defaults toTrue. - debug (bool, default False): Verbosity toggle. Defaults to False.
- kw (Any): Catch-all for keyword arguments.
Returns
- A
SuccessTupleof success (bool) and message (str).
2325def sync_pipe_inplace( 2326 self, 2327 pipe: 'mrsm.Pipe', 2328 params: Optional[Dict[str, Any]] = None, 2329 begin: Union[datetime, int, None] = None, 2330 end: Union[datetime, int, None] = None, 2331 chunksize: Optional[int] = -1, 2332 check_existing: bool = True, 2333 debug: bool = False, 2334 **kw: Any 2335) -> SuccessTuple: 2336 """ 2337 If a pipe's connector is the same as its instance connector, 2338 it's more efficient to sync the pipe in-place rather than reading data into Pandas. 2339 2340 Parameters 2341 ---------- 2342 pipe: mrsm.Pipe 2343 The pipe whose connector is the same as its instance. 2344 2345 params: Optional[Dict[str, Any]], default None 2346 Optional params dictionary to build the `WHERE` clause. 2347 See `meerschaum.utils.sql.build_where`. 2348 2349 begin: Union[datetime, int, None], default None 2350 Optionally specify the earliest datetime to search for data. 2351 Defaults to `None`. 2352 2353 end: Union[datetime, int, None], default None 2354 Optionally specify the latest datetime to search for data. 2355 Defaults to `None`. 2356 2357 chunksize: Optional[int], default -1 2358 Specify the number of rows to sync per chunk. 2359 If `-1`, resort to system configuration (default is `900`). 2360 A `chunksize` of `None` will sync all rows in one transaction. 2361 Defaults to `-1`. 2362 2363 check_existing: bool, default True 2364 If `True`, pull and diff with existing data from the pipe. 2365 2366 debug: bool, default False 2367 Verbosity toggle. 2368 2369 Returns 2370 ------- 2371 A SuccessTuple. 2372 """ 2373 if self.flavor == 'duckdb': 2374 return pipe.sync( 2375 params=params, 2376 begin=begin, 2377 end=end, 2378 chunksize=chunksize, 2379 check_existing=check_existing, 2380 debug=debug, 2381 _inplace=False, 2382 **kw 2383 ) 2384 from meerschaum.utils.sql import ( 2385 sql_item_name, 2386 get_update_queries, 2387 get_null_replacement, 2388 get_create_table_queries, 2389 get_create_schema_if_not_exists_queries, 2390 get_table_cols_types, 2391 session_execute, 2392 dateadd_str, 2393 UPDATE_QUERIES, 2394 ) 2395 from meerschaum.utils.dtypes.sql import ( 2396 get_pd_type_from_db_type, 2397 get_db_type_from_pd_type, 2398 ) 2399 from meerschaum.utils.misc import generate_password 2400 2401 transaction_id_length = ( 2402 mrsm.get_config( 2403 'system', 'connectors', 'sql', 'instance', 'temporary_target', 'transaction_id_length' 2404 ) 2405 ) 2406 transact_id = generate_password(transaction_id_length) 2407 2408 internal_schema = self.internal_schema 2409 target = pipe.target 2410 temp_table_roots = ['backtrack', 'new', 'delta', 'joined', 'unseen', 'update'] 2411 temp_tables = { 2412 table_root: self.get_temporary_target(target, transact_id=transact_id, label=table_root) 2413 for table_root in temp_table_roots 2414 } 2415 temp_table_names = { 2416 table_root: sql_item_name(table_name_raw, self.flavor, internal_schema) 2417 for table_root, table_name_raw in temp_tables.items() 2418 } 2419 temp_table_aliases = { 2420 table_root: sql_item_name(table_root, self.flavor) 2421 for table_root in temp_table_roots 2422 } 2423 table_alias_as = " AS" if self.flavor != 'oracle' else '' 2424 metadef = self.get_pipe_metadef( 2425 pipe, 2426 params=params, 2427 begin=begin, 2428 end=end, 2429 check_existing=check_existing, 2430 debug=debug, 2431 ) 2432 pipe_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe)) 2433 upsert = pipe.parameters.get('upsert', False) and f'{self.flavor}-upsert' in UPDATE_QUERIES 2434 static = pipe.parameters.get('static', False) 2435 database = getattr(self, 'database', self.parse_uri(self.URI).get('database', None)) 2436 primary_key = pipe.columns.get('primary', None) 2437 primary_key_typ = pipe.dtypes.get(primary_key, None) if primary_key else None 2438 primary_key_db_type = ( 2439 get_db_type_from_pd_type(primary_key_typ, self.flavor) 2440 if primary_key_typ 2441 else None 2442 ) 2443 if not {col_key: col for col_key, col in pipe.columns.items() if col_key and col}: 2444 return False, "Cannot sync in-place without index columns." 2445 2446 autoincrement = pipe.parameters.get('autoincrement', False) 2447 dt_col = pipe.columns.get('datetime', None) 2448 dt_col_name = sql_item_name(dt_col, self.flavor, None) if dt_col else None 2449 dt_typ = pipe.dtypes.get(dt_col, 'datetime') if dt_col else None 2450 dt_db_type = get_db_type_from_pd_type(dt_typ, self.flavor) if dt_typ else None 2451 2452 def clean_up_temp_tables(ready_to_drop: bool = False): 2453 log_success, log_msg = self._log_temporary_tables_creation( 2454 [ 2455 table 2456 for table in temp_tables.values() 2457 ] if not upsert else [temp_tables['update']], 2458 ready_to_drop=ready_to_drop, 2459 create=(not pipe.temporary), 2460 debug=debug, 2461 ) 2462 if not log_success: 2463 warn(log_msg) 2464 drop_stale_success, drop_stale_msg = self._drop_old_temporary_tables( 2465 refresh=False, 2466 debug=debug, 2467 ) 2468 if not drop_stale_success: 2469 warn(drop_stale_msg) 2470 return drop_stale_success, drop_stale_msg 2471 2472 sqlalchemy, sqlalchemy_orm = mrsm.attempt_import( 2473 'sqlalchemy', 2474 'sqlalchemy.orm', 2475 ) 2476 if not pipe.exists(debug=debug): 2477 schema = self.get_pipe_schema(pipe) 2478 create_pipe_queries = get_create_table_queries( 2479 metadef, 2480 pipe.target, 2481 self.flavor, 2482 schema=schema, 2483 primary_key=primary_key, 2484 primary_key_db_type=primary_key_db_type, 2485 autoincrement=autoincrement, 2486 datetime_column=dt_col, 2487 ) 2488 if schema: 2489 create_pipe_queries = ( 2490 get_create_schema_if_not_exists_queries(schema, self.flavor) 2491 + create_pipe_queries 2492 ) 2493 2494 results = self.exec_queries(create_pipe_queries, debug=debug) 2495 if not all(results): 2496 _ = clean_up_temp_tables() 2497 return False, f"Could not insert new data into {pipe} from its SQL query definition." 2498 2499 if not self.create_indices(pipe, debug=debug): 2500 warn(f"Failed to create indices for {pipe}. Continuing...") 2501 2502 rowcount = pipe.get_rowcount(debug=debug) 2503 _ = clean_up_temp_tables() 2504 return True, f"Inserted {rowcount:,}, updated 0 rows." 2505 2506 session = sqlalchemy_orm.Session(self.engine) 2507 connectable = session if self.flavor != 'duckdb' else self 2508 2509 create_new_query = get_create_table_queries( 2510 metadef, 2511 temp_tables[('new') if not upsert else 'update'], 2512 self.flavor, 2513 schema=internal_schema, 2514 )[0] 2515 (create_new_success, create_new_msg), create_new_results = session_execute( 2516 session, 2517 create_new_query, 2518 with_results=True, 2519 debug=debug, 2520 ) 2521 if not create_new_success: 2522 _ = clean_up_temp_tables() 2523 return create_new_success, create_new_msg 2524 new_count = create_new_results[0].rowcount if create_new_results else 0 2525 2526 new_cols_types = get_table_cols_types( 2527 temp_tables[('new' if not upsert else 'update')], 2528 connectable=connectable, 2529 flavor=self.flavor, 2530 schema=internal_schema, 2531 database=database, 2532 debug=debug, 2533 ) if not static else pipe.get_columns_types(debug=debug) 2534 if not new_cols_types: 2535 return False, f"Failed to get new columns for {pipe}." 2536 2537 new_cols = { 2538 str(col_name): get_pd_type_from_db_type(str(col_type)) 2539 for col_name, col_type in new_cols_types.items() 2540 } 2541 new_cols_str = '\n ' + ',\n '.join([ 2542 sql_item_name(col, self.flavor) 2543 for col in new_cols 2544 ]) 2545 def get_col_typ(col: str, cols_types: Dict[str, str]) -> str: 2546 if self.flavor == 'oracle' and new_cols_types.get(col, '').lower() == 'char': 2547 return new_cols_types[col] 2548 return cols_types[col] 2549 2550 add_cols_queries = self.get_add_columns_queries(pipe, new_cols, debug=debug) 2551 if add_cols_queries: 2552 pipe._clear_cache_key('_columns_types', debug=debug) 2553 pipe._clear_cache_key('_columns_indices', debug=debug) 2554 self.exec_queries(add_cols_queries, debug=debug) 2555 2556 alter_cols_queries = self.get_alter_columns_queries(pipe, new_cols, debug=debug) 2557 if alter_cols_queries: 2558 pipe._clear_cache_key('_columns_types', debug=debug) 2559 self.exec_queries(alter_cols_queries, debug=debug) 2560 2561 insert_queries = [ 2562 ( 2563 f"INSERT INTO {pipe_name} ({new_cols_str})\n" 2564 f"SELECT {new_cols_str}\nFROM {temp_table_names['new']}{table_alias_as}" 2565 f" {temp_table_aliases['new']}" 2566 ) 2567 ] if not check_existing and not upsert else [] 2568 2569 new_queries = insert_queries 2570 new_success, new_msg = ( 2571 session_execute(session, new_queries, debug=debug) 2572 if new_queries 2573 else (True, "Success") 2574 ) 2575 if not new_success: 2576 _ = clean_up_temp_tables() 2577 return new_success, new_msg 2578 2579 if not check_existing: 2580 session.commit() 2581 _ = clean_up_temp_tables() 2582 return True, f"Inserted {new_count}, updated 0 rows." 2583 2584 min_dt_col_name_da = dateadd_str( 2585 flavor=self.flavor, begin=f"MIN({dt_col_name})", db_type=dt_db_type, 2586 ) 2587 max_dt_col_name_da = dateadd_str( 2588 flavor=self.flavor, begin=f"MAX({dt_col_name})", db_type=dt_db_type, 2589 ) 2590 2591 (new_dt_bounds_success, new_dt_bounds_msg), new_dt_bounds_results = session_execute( 2592 session, 2593 [ 2594 "SELECT\n" 2595 f" {min_dt_col_name_da} AS {sql_item_name('min_dt', self.flavor)},\n" 2596 f" {max_dt_col_name_da} AS {sql_item_name('max_dt', self.flavor)}\n" 2597 f"FROM {temp_table_names['new' if not upsert else 'update']}\n" 2598 f"WHERE {dt_col_name} IS NOT NULL" 2599 ], 2600 with_results=True, 2601 debug=debug, 2602 ) if dt_col and not upsert else ((True, "Success"), None) 2603 if not new_dt_bounds_success: 2604 return ( 2605 new_dt_bounds_success, 2606 f"Could not determine in-place datetime bounds:\n{new_dt_bounds_msg}" 2607 ) 2608 2609 if dt_col and not upsert: 2610 begin, end = new_dt_bounds_results[0].fetchone() 2611 2612 backtrack_def = self.get_pipe_data_query( 2613 pipe, 2614 begin=begin, 2615 end=end, 2616 begin_add_minutes=0, 2617 end_add_minutes=1, 2618 params=params, 2619 debug=debug, 2620 order=None, 2621 ) 2622 create_backtrack_query = get_create_table_queries( 2623 backtrack_def, 2624 temp_tables['backtrack'], 2625 self.flavor, 2626 schema=internal_schema, 2627 )[0] 2628 (create_backtrack_success, create_backtrack_msg), create_backtrack_results = session_execute( 2629 session, 2630 create_backtrack_query, 2631 with_results=True, 2632 debug=debug, 2633 ) if not upsert else ((True, "Success"), None) 2634 2635 if not create_backtrack_success: 2636 _ = clean_up_temp_tables() 2637 return create_backtrack_success, create_backtrack_msg 2638 2639 backtrack_cols_types = get_table_cols_types( 2640 temp_tables['backtrack'], 2641 connectable=connectable, 2642 flavor=self.flavor, 2643 schema=internal_schema, 2644 database=database, 2645 debug=debug, 2646 ) if not (upsert or static) else new_cols_types 2647 2648 common_cols = [col for col in new_cols if col in backtrack_cols_types] 2649 primary_key = pipe.columns.get('primary', None) 2650 on_cols = { 2651 col: new_cols.get(col) 2652 for col_key, col in pipe.columns.items() 2653 if ( 2654 col 2655 and 2656 col_key != 'value' 2657 and col in backtrack_cols_types 2658 and col in new_cols 2659 ) 2660 } if not primary_key else {primary_key: new_cols.get(primary_key)} 2661 if not on_cols: 2662 raise ValueError("Cannot sync without common index columns.") 2663 2664 null_replace_new_cols_str = ( 2665 '\n ' + ',\n '.join([ 2666 f"COALESCE({temp_table_aliases['new']}.{sql_item_name(col, self.flavor)}, " 2667 + get_null_replacement(get_col_typ(col, new_cols_types), self.flavor) 2668 + ") AS " 2669 + sql_item_name(col, self.flavor, None) 2670 for col, typ in new_cols.items() 2671 ]) 2672 ) 2673 2674 select_delta_query = ( 2675 "SELECT" 2676 + null_replace_new_cols_str 2677 + f"\nFROM {temp_table_names['new']}{table_alias_as} {temp_table_aliases['new']}\n" 2678 + f"LEFT OUTER JOIN {temp_table_names['backtrack']}{table_alias_as} {temp_table_aliases['backtrack']}" 2679 + "\n ON\n " 2680 + '\n AND\n '.join([ 2681 ( 2682 f" COALESCE({temp_table_aliases['new']}." 2683 + sql_item_name(c, self.flavor, None) 2684 + ", " 2685 + get_null_replacement(get_col_typ(c, new_cols_types), self.flavor) 2686 + ")" 2687 + '\n =\n ' 2688 + f" COALESCE({temp_table_aliases['backtrack']}." 2689 + sql_item_name(c, self.flavor, None) 2690 + ", " 2691 + get_null_replacement(get_col_typ(c, backtrack_cols_types), self.flavor) 2692 + ") " 2693 ) for c in common_cols 2694 ]) 2695 + "\nWHERE\n " 2696 + '\n AND\n '.join([ 2697 ( 2698 f"{temp_table_aliases['backtrack']}." + sql_item_name(c, self.flavor) + ' IS NULL' 2699 ) for c in common_cols 2700 ]) 2701 ) 2702 create_delta_query = get_create_table_queries( 2703 select_delta_query, 2704 temp_tables['delta'], 2705 self.flavor, 2706 schema=internal_schema, 2707 )[0] 2708 create_delta_success, create_delta_msg = session_execute( 2709 session, 2710 create_delta_query, 2711 debug=debug, 2712 ) if not upsert else (True, "Success") 2713 if not create_delta_success: 2714 _ = clean_up_temp_tables() 2715 return create_delta_success, create_delta_msg 2716 2717 delta_cols_types = get_table_cols_types( 2718 temp_tables['delta'], 2719 connectable=connectable, 2720 flavor=self.flavor, 2721 schema=internal_schema, 2722 database=database, 2723 debug=debug, 2724 ) if not (upsert or static) else new_cols_types 2725 2726 ### This is a weird bug on SQLite. 2727 ### Sometimes the backtrack dtypes are all empty strings. 2728 if not all(delta_cols_types.values()): 2729 delta_cols_types = new_cols_types 2730 2731 delta_cols = { 2732 col: get_pd_type_from_db_type(typ) 2733 for col, typ in delta_cols_types.items() 2734 } 2735 delta_cols_str = ', '.join([ 2736 sql_item_name(col, self.flavor) 2737 for col in delta_cols 2738 ]) 2739 2740 select_joined_query = ( 2741 "SELECT\n " 2742 + (',\n '.join([ 2743 ( 2744 f"{temp_table_aliases['delta']}." + sql_item_name(c, self.flavor, None) 2745 + " AS " + sql_item_name(c + '_delta', self.flavor, None) 2746 ) for c in delta_cols 2747 ])) 2748 + ",\n " 2749 + (',\n '.join([ 2750 ( 2751 f"{temp_table_aliases['backtrack']}." + sql_item_name(c, self.flavor, None) 2752 + " AS " + sql_item_name(c + '_backtrack', self.flavor, None) 2753 ) for c in backtrack_cols_types 2754 ])) 2755 + f"\nFROM {temp_table_names['delta']}{table_alias_as} {temp_table_aliases['delta']}\n" 2756 + f"LEFT OUTER JOIN {temp_table_names['backtrack']}{table_alias_as}" 2757 + f" {temp_table_aliases['backtrack']}" 2758 + "\n ON\n " 2759 + '\n AND\n '.join([ 2760 ( 2761 f" COALESCE({temp_table_aliases['delta']}." + sql_item_name(c, self.flavor) 2762 + ", " 2763 + get_null_replacement(get_col_typ(c, new_cols_types), self.flavor) + ")" 2764 + '\n =\n ' 2765 + f" COALESCE({temp_table_aliases['backtrack']}." + sql_item_name(c, self.flavor) 2766 + ", " 2767 + get_null_replacement(get_col_typ(c, new_cols_types), self.flavor) + ")" 2768 ) for c, typ in on_cols.items() 2769 ]) 2770 ) 2771 2772 create_joined_query = get_create_table_queries( 2773 select_joined_query, 2774 temp_tables['joined'], 2775 self.flavor, 2776 schema=internal_schema, 2777 )[0] 2778 create_joined_success, create_joined_msg = session_execute( 2779 session, 2780 create_joined_query, 2781 debug=debug, 2782 ) if on_cols and not upsert else (True, "Success") 2783 if not create_joined_success: 2784 _ = clean_up_temp_tables() 2785 return create_joined_success, create_joined_msg 2786 2787 select_unseen_query = ( 2788 "SELECT\n " 2789 + (',\n '.join([ 2790 ( 2791 "CASE\n WHEN " + sql_item_name(c + '_delta', self.flavor, None) 2792 + " != " + get_null_replacement(get_col_typ(c, delta_cols_types), self.flavor) 2793 + " THEN " + sql_item_name(c + '_delta', self.flavor, None) 2794 + "\n ELSE NULL\n END" 2795 + " AS " + sql_item_name(c, self.flavor, None) 2796 ) for c, typ in delta_cols.items() 2797 ])) 2798 + f"\nFROM {temp_table_names['joined']}{table_alias_as} {temp_table_aliases['joined']}\n" 2799 + "WHERE\n " 2800 + '\n AND\n '.join([ 2801 ( 2802 sql_item_name(c + '_backtrack', self.flavor, None) + ' IS NULL' 2803 ) for c in delta_cols 2804 ]) 2805 ) 2806 create_unseen_query = get_create_table_queries( 2807 select_unseen_query, 2808 temp_tables['unseen'], 2809 self.flavor, 2810 internal_schema, 2811 )[0] 2812 (create_unseen_success, create_unseen_msg), create_unseen_results = session_execute( 2813 session, 2814 create_unseen_query, 2815 with_results=True, 2816 debug=debug 2817 ) if not upsert else ((True, "Success"), None) 2818 if not create_unseen_success: 2819 _ = clean_up_temp_tables() 2820 return create_unseen_success, create_unseen_msg 2821 2822 select_update_query = ( 2823 "SELECT\n " 2824 + (',\n '.join([ 2825 ( 2826 "CASE\n WHEN " + sql_item_name(c + '_delta', self.flavor, None) 2827 + " != " + get_null_replacement(get_col_typ(c, delta_cols_types), self.flavor) 2828 + " THEN " + sql_item_name(c + '_delta', self.flavor, None) 2829 + "\n ELSE NULL\n END" 2830 + " AS " + sql_item_name(c, self.flavor, None) 2831 ) for c, typ in delta_cols.items() 2832 ])) 2833 + f"\nFROM {temp_table_names['joined']}{table_alias_as} {temp_table_aliases['joined']}\n" 2834 + "WHERE\n " 2835 + '\n OR\n '.join([ 2836 ( 2837 sql_item_name(c + '_backtrack', self.flavor, None) + ' IS NOT NULL' 2838 ) for c in delta_cols 2839 ]) 2840 ) 2841 2842 create_update_query = get_create_table_queries( 2843 select_update_query, 2844 temp_tables['update'], 2845 self.flavor, 2846 internal_schema, 2847 )[0] 2848 (create_update_success, create_update_msg), create_update_results = session_execute( 2849 session, 2850 create_update_query, 2851 with_results=True, 2852 debug=debug, 2853 ) if on_cols and not upsert else ((True, "Success"), []) 2854 apply_update_queries = ( 2855 get_update_queries( 2856 pipe.target, 2857 temp_tables['update'], 2858 session, 2859 on_cols, 2860 upsert=upsert, 2861 schema=self.get_pipe_schema(pipe), 2862 patch_schema=internal_schema, 2863 target_cols_types=pipe.get_columns_types(debug=debug), 2864 patch_cols_types=delta_cols_types, 2865 datetime_col=pipe.columns.get('datetime', None), 2866 flavor=self.flavor, 2867 null_indices=pipe.null_indices, 2868 cast_columns=pipe.enforce, 2869 debug=debug, 2870 ) 2871 if on_cols else [] 2872 ) 2873 2874 apply_unseen_queries = [ 2875 ( 2876 f"INSERT INTO {pipe_name} ({delta_cols_str})\n" 2877 + f"SELECT {delta_cols_str}\nFROM " 2878 + ( 2879 temp_table_names['unseen'] 2880 if on_cols 2881 else temp_table_names['delta'] 2882 ) 2883 ), 2884 ] 2885 2886 (apply_unseen_success, apply_unseen_msg), apply_unseen_results = session_execute( 2887 session, 2888 apply_unseen_queries, 2889 with_results=True, 2890 debug=debug, 2891 ) if not upsert else ((True, "Success"), None) 2892 if not apply_unseen_success: 2893 _ = clean_up_temp_tables() 2894 return apply_unseen_success, apply_unseen_msg 2895 unseen_count = apply_unseen_results[0].rowcount if apply_unseen_results else 0 2896 2897 (apply_update_success, apply_update_msg), apply_update_results = session_execute( 2898 session, 2899 apply_update_queries, 2900 with_results=True, 2901 debug=debug, 2902 ) 2903 if not apply_update_success: 2904 _ = clean_up_temp_tables() 2905 return apply_update_success, apply_update_msg 2906 update_count = apply_update_results[0].rowcount if apply_update_results else 0 2907 2908 session.commit() 2909 2910 msg = ( 2911 f"Inserted {unseen_count:,}, updated {update_count:,} rows." 2912 if not upsert 2913 else f"Upserted {update_count:,} row" + ('s' if update_count != 1 else '') + "." 2914 ) 2915 _ = clean_up_temp_tables(ready_to_drop=True) 2916 2917 return True, msg
If a pipe's connector is the same as its instance connector, it's more efficient to sync the pipe in-place rather than reading data into Pandas.
Parameters
- pipe (mrsm.Pipe): The pipe whose connector is the same as its instance.
- params (Optional[Dict[str, Any]], default None):
Optional params dictionary to build the
WHEREclause. Seemeerschaum.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 is900). AchunksizeofNonewill 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.
2920def get_sync_time( 2921 self, 2922 pipe: 'mrsm.Pipe', 2923 params: Optional[Dict[str, Any]] = None, 2924 newest: bool = True, 2925 remote: bool = False, 2926 debug: bool = False, 2927) -> Union[datetime, int, None]: 2928 """Get a Pipe's most recent datetime value. 2929 2930 Parameters 2931 ---------- 2932 pipe: mrsm.Pipe 2933 The pipe to get the sync time for. 2934 2935 params: Optional[Dict[str, Any]], default None 2936 Optional params dictionary to build the `WHERE` clause. 2937 See `meerschaum.utils.sql.build_where`. 2938 2939 newest: bool, default True 2940 If `True`, get the most recent datetime (honoring `params`). 2941 If `False`, get the oldest datetime (ASC instead of DESC). 2942 2943 remote: bool, default False 2944 If `True`, return the sync time for the remote fetch definition. 2945 2946 Returns 2947 ------- 2948 A `datetime` object (or `int` if using an integer axis) if the pipe exists, otherwise `None`. 2949 """ 2950 from meerschaum.utils.sql import sql_item_name, build_where, wrap_query_with_cte 2951 src_name = sql_item_name('src', self.flavor) 2952 table_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe)) 2953 2954 dt_col = pipe.columns.get('datetime', None) 2955 if dt_col is None: 2956 return None 2957 dt_col_name = sql_item_name(dt_col, self.flavor, None) 2958 2959 if remote and pipe.connector.type != 'sql': 2960 warn(f"Cannot get the remote sync time for {pipe}.") 2961 return None 2962 2963 ASC_or_DESC = "DESC" if newest else "ASC" 2964 existing_cols = pipe.get_columns_types(debug=debug) 2965 if not remote and not existing_cols: 2966 return None 2967 valid_params = {} 2968 if params is not None: 2969 valid_params = {k: v for k, v in params.items() if k in existing_cols} 2970 flavor = self.flavor if not remote else pipe.connector.flavor 2971 2972 ### If no bounds are provided for the datetime column, 2973 ### add IS NOT NULL to the WHERE clause. 2974 if dt_col not in valid_params: 2975 valid_params[dt_col] = '_None' 2976 where = "" if not valid_params else build_where(valid_params, self) 2977 src_query = ( 2978 f"SELECT {dt_col_name}\nFROM {table_name}{where}" 2979 if not remote 2980 else self.get_pipe_metadef(pipe, params=params, begin=None, end=None) 2981 ) 2982 2983 base_query = ( 2984 f"SELECT {dt_col_name}\n" 2985 f"FROM {src_name}\n" 2986 f"ORDER BY {dt_col_name} {ASC_or_DESC}\n" 2987 f"LIMIT 1" 2988 ) 2989 if self.flavor == 'mssql': 2990 base_query = ( 2991 f"SELECT TOP 1 {dt_col_name}\n" 2992 f"FROM {src_name}\n" 2993 f"ORDER BY {dt_col_name} {ASC_or_DESC}" 2994 ) 2995 elif self.flavor == 'oracle': 2996 base_query = ( 2997 "SELECT * FROM (\n" 2998 f" SELECT {dt_col_name}\n" 2999 f" FROM {src_name}\n" 3000 f" ORDER BY {dt_col_name} {ASC_or_DESC}\n" 3001 ") WHERE ROWNUM = 1" 3002 ) 3003 3004 ### NOTE: MariaDB has an optimizer bug where `ORDER BY <dt> DESC/ASC LIMIT 1` against a 3005 ### `RANGE COLUMNS` partitioned table combined with a `WHERE` clause performs a partition 3006 ### index scan that stops early and returns zero rows (observed on MariaDB 12.x). The 3007 ### equivalent `MIN`/`MAX` aggregate scans the pruned partitions correctly, so use it for 3008 ### the bounds on partitioned MariaDB tables instead. 3009 if self.flavor == 'mariadb' and not remote and self._should_partition(pipe): 3010 agg_func = "MAX" if newest else "MIN" 3011 base_query = ( 3012 f"SELECT {agg_func}({dt_col_name}) AS {dt_col_name}\n" 3013 f"FROM {src_name}" 3014 ) 3015 3016 query = wrap_query_with_cte(src_query, base_query, flavor) 3017 3018 try: 3019 db_time = self.value(query, silent=True, debug=debug) 3020 3021 ### No datetime could be found. 3022 if db_time is None: 3023 return None 3024 ### sqlite returns str. 3025 if isinstance(db_time, str): 3026 dateutil_parser = mrsm.attempt_import('dateutil.parser') 3027 st = dateutil_parser.parse(db_time) 3028 ### Do nothing if a datetime object is returned. 3029 elif isinstance(db_time, datetime): 3030 if hasattr(db_time, 'to_pydatetime'): 3031 st = db_time.to_pydatetime() 3032 else: 3033 st = db_time 3034 ### Sometimes the datetime is actually a date. 3035 elif isinstance(db_time, date): 3036 st = datetime.combine(db_time, datetime.min.time()) 3037 ### Adding support for an integer datetime axis. 3038 elif 'int' in str(type(db_time)).lower(): 3039 st = int(db_time) 3040 ### Convert pandas timestamp to Python datetime. 3041 else: 3042 st = db_time.to_pydatetime() 3043 3044 sync_time = st 3045 3046 except Exception as e: 3047 sync_time = None 3048 warn(str(e)) 3049 3050 return sync_time
Get a Pipe's most recent datetime value.
Parameters
- pipe (mrsm.Pipe): The pipe to get the sync time for.
- params (Optional[Dict[str, Any]], default None):
Optional params dictionary to build the
WHEREclause. Seemeerschaum.utils.sql.build_where. - newest (bool, default True):
If
True, get the most recent datetime (honoringparams). IfFalse, 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
datetimeobject (orintif using an integer axis) if the pipe exists, otherwiseNone.
3053def pipe_exists( 3054 self, 3055 pipe: mrsm.Pipe, 3056 debug: bool = False 3057) -> bool: 3058 """ 3059 Check that a Pipe's table exists. 3060 3061 Parameters 3062 ---------- 3063 pipe: mrsm.Pipe: 3064 The pipe to check. 3065 3066 debug: bool, default False 3067 Verbosity toggle. 3068 3069 Returns 3070 ------- 3071 A `bool` corresponding to whether a pipe's table exists. 3072 3073 """ 3074 from meerschaum.utils.sql import table_exists 3075 exists = table_exists( 3076 pipe.target, 3077 self, 3078 schema=self.get_pipe_schema(pipe), 3079 debug=debug, 3080 ) 3081 if debug: 3082 dprint(f"{pipe} " + ('exists.' if exists else 'does not exist.')) 3083 return exists
Check that a Pipe's table exists.
Parameters
- pipe (mrsm.Pipe:): The pipe to check.
- debug (bool, default False): Verbosity toggle.
Returns
- A
boolcorresponding to whether a pipe's table exists.
3086def get_pipe_rowcount( 3087 self, 3088 pipe: mrsm.Pipe, 3089 begin: Union[datetime, int, None] = None, 3090 end: Union[datetime, int, None] = None, 3091 params: Optional[Dict[str, Any]] = None, 3092 remote: bool = False, 3093 debug: bool = False 3094) -> Union[int, None]: 3095 """ 3096 Get the rowcount for a pipe in accordance with given parameters. 3097 3098 Parameters 3099 ---------- 3100 pipe: mrsm.Pipe 3101 The pipe to query with. 3102 3103 begin: Union[datetime, int, None], default None 3104 The begin datetime value. 3105 3106 end: Union[datetime, int, None], default None 3107 The end datetime value. 3108 3109 params: Optional[Dict[str, Any]], default None 3110 See `meerschaum.utils.sql.build_where`. 3111 3112 remote: bool, default False 3113 If `True`, get the rowcount for the remote table. 3114 3115 debug: bool, default False 3116 Verbosity toggle. 3117 3118 Returns 3119 ------- 3120 An `int` for the number of rows if the `pipe` exists, otherwise `None`. 3121 3122 """ 3123 from meerschaum.utils.sql import dateadd_str, sql_item_name, wrap_query_with_cte, build_where 3124 from meerschaum.connectors.sql._fetch import get_pipe_query 3125 from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type 3126 if remote: 3127 msg = f"'fetch:definition' must be an attribute of {pipe} to get a remote rowcount." 3128 if 'fetch' not in pipe.parameters: 3129 error(msg) 3130 return None 3131 if 'definition' not in pipe.parameters['fetch']: 3132 error(msg) 3133 return None 3134 elif not pipe.exists(debug=debug): 3135 return None 3136 3137 flavor = self.flavor if not remote else pipe.connector.flavor 3138 conn = self if not remote else pipe.connector 3139 _pipe_name = sql_item_name(pipe.target, flavor, self.get_pipe_schema(pipe)) 3140 dt_col = pipe.columns.get('datetime', None) 3141 dt_typ = pipe.dtypes.get(dt_col, 'datetime') if dt_col else None 3142 dt_db_type = get_db_type_from_pd_type(dt_typ, flavor) if dt_typ else None 3143 if not dt_col: 3144 dt_col = pipe.guess_datetime() 3145 dt_name = sql_item_name(dt_col, flavor, None) if dt_col else None 3146 is_guess = True 3147 else: 3148 dt_col = pipe.get_columns('datetime') 3149 dt_name = sql_item_name(dt_col, flavor, None) 3150 is_guess = False 3151 3152 if begin is not None or end is not None: 3153 if is_guess: 3154 if dt_col is None: 3155 warn( 3156 f"No datetime could be determined for {pipe}." 3157 + "\n Ignoring begin and end...", 3158 stack=False, 3159 ) 3160 begin, end = None, None 3161 else: 3162 warn( 3163 f"A datetime wasn't specified for {pipe}.\n" 3164 + f" Using column \"{dt_col}\" for datetime bounds...", 3165 stack=False, 3166 ) 3167 3168 3169 _datetime_name = sql_item_name(dt_col, flavor) 3170 _cols_names = [ 3171 sql_item_name(col, flavor) 3172 for col in set( 3173 ( 3174 [dt_col] 3175 if dt_col 3176 else [] 3177 ) + ( 3178 [] 3179 if params is None 3180 else list(params.keys()) 3181 ) 3182 ) 3183 ] 3184 if not _cols_names: 3185 _cols_names = ['*'] 3186 3187 src = ( 3188 f"SELECT {', '.join(_cols_names)}\nFROM {_pipe_name}" 3189 if not remote 3190 else get_pipe_query(pipe) 3191 ) 3192 parent_query = f"SELECT COUNT(*)\nFROM {sql_item_name('src', flavor)}" 3193 query = wrap_query_with_cte(src, parent_query, flavor) 3194 if begin is not None or end is not None: 3195 query += "\nWHERE" 3196 if begin is not None: 3197 query += ( 3198 f"\n {dt_name} >= " 3199 + dateadd_str(flavor, datepart='minute', number=0, begin=begin, db_type=dt_db_type) 3200 ) 3201 if end is not None and begin is not None: 3202 query += "\n AND" 3203 if end is not None: 3204 query += ( 3205 f"\n {dt_name} < " 3206 + dateadd_str(flavor, datepart='minute', number=0, begin=end, db_type=dt_db_type) 3207 ) 3208 if params is not None: 3209 existing_cols = pipe.get_columns_types(debug=debug) 3210 valid_params = {k: v for k, v in params.items() if k in existing_cols} 3211 if valid_params: 3212 query += build_where(valid_params, conn).replace('WHERE', ( 3213 'AND' if (begin is not None or end is not None) 3214 else 'WHERE' 3215 ) 3216 ) 3217 3218 result = conn.value(query, debug=debug, silent=True) 3219 try: 3220 return int(result) 3221 except Exception: 3222 return None
Get the rowcount for a pipe in accordance with given parameters.
Parameters
- pipe (mrsm.Pipe): The pipe to query with.
- begin (Union[datetime, int, None], default None): The begin datetime value.
- end (Union[datetime, int, None], default None): The end datetime value.
- params (Optional[Dict[str, Any]], default None):
See
meerschaum.utils.sql.build_where. - remote (bool, default False):
If
True, get the rowcount for the remote table. - debug (bool, default False): Verbosity toggle.
Returns
- An
intfor the number of rows if thepipeexists, otherwiseNone.
3225def drop_pipe( 3226 self, 3227 pipe: mrsm.Pipe, 3228 debug: bool = False, 3229 **kw 3230) -> SuccessTuple: 3231 """ 3232 Drop a pipe's tables but maintain its registration. 3233 3234 Parameters 3235 ---------- 3236 pipe: mrsm.Pipe 3237 The pipe to drop. 3238 3239 Returns 3240 ------- 3241 A `SuccessTuple` indicated success. 3242 """ 3243 from meerschaum.utils.sql import table_exists, sql_item_name, DROP_IF_EXISTS_FLAVORS 3244 success = True 3245 target = pipe.target 3246 schema = self.get_pipe_schema(pipe) 3247 target_name = ( 3248 sql_item_name(target, self.flavor, schema) 3249 ) 3250 if table_exists(target, self, schema=schema, debug=debug): 3251 if_exists_str = "IF EXISTS" if self.flavor in DROP_IF_EXISTS_FLAVORS else "" 3252 success = self.exec( 3253 f"DROP TABLE {if_exists_str} {target_name}", silent=True, debug=debug 3254 ) is not None 3255 3256 ### Drop any MSSQL partition scheme + function the table referenced (no-op otherwise). 3257 if success: 3258 cleanup_queries = self._get_partition_cleanup_queries(pipe) 3259 if cleanup_queries: 3260 self.exec_queries(cleanup_queries, break_on_error=False, silent=True, debug=debug) 3261 3262 msg = "Success" if success else f"Failed to drop {pipe}." 3263 return success, msg
Drop a pipe's tables but maintain its registration.
Parameters
- pipe (mrsm.Pipe): The pipe to drop.
Returns
- A
SuccessTupleindicated success.
3266def clear_pipe( 3267 self, 3268 pipe: mrsm.Pipe, 3269 begin: Union[datetime, int, None] = None, 3270 end: Union[datetime, int, None] = None, 3271 params: Optional[Dict[str, Any]] = None, 3272 debug: bool = False, 3273 **kw 3274) -> SuccessTuple: 3275 """ 3276 Delete a pipe's data within a bounded or unbounded interval without dropping the table. 3277 3278 Parameters 3279 ---------- 3280 pipe: mrsm.Pipe 3281 The pipe to clear. 3282 3283 begin: Union[datetime, int, None], default None 3284 Beginning datetime. Inclusive. 3285 3286 end: Union[datetime, int, None], default None 3287 Ending datetime. Exclusive. 3288 3289 params: Optional[Dict[str, Any]], default None 3290 See `meerschaum.utils.sql.build_where`. 3291 3292 """ 3293 if not pipe.exists(debug=debug): 3294 return True, f"{pipe} does not exist, so nothing was cleared." 3295 3296 from meerschaum.utils.sql import sql_item_name, build_where, dateadd_str 3297 from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type 3298 pipe_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe)) 3299 3300 dt_col = pipe.columns.get('datetime', None) 3301 dt_typ = pipe.dtypes.get(dt_col, 'datetime') if dt_col else None 3302 dt_db_type = get_db_type_from_pd_type(dt_typ, self.flavor) if dt_typ else None 3303 if not pipe.columns.get('datetime', None): 3304 dt_col = pipe.guess_datetime() 3305 dt_name = sql_item_name(dt_col, self.flavor, None) if dt_col else None 3306 is_guess = True 3307 else: 3308 dt_col = pipe.get_columns('datetime') 3309 dt_name = sql_item_name(dt_col, self.flavor, None) 3310 is_guess = False 3311 3312 if begin is not None or end is not None: 3313 if is_guess: 3314 if dt_col is None: 3315 warn( 3316 f"No datetime could be determined for {pipe}." 3317 + "\n Ignoring datetime bounds...", 3318 stack=False, 3319 ) 3320 begin, end = None, None 3321 else: 3322 warn( 3323 f"A datetime wasn't specified for {pipe}.\n" 3324 + f" Using column \"{dt_col}\" for datetime bounds...", 3325 stack=False, 3326 ) 3327 3328 valid_params = {} 3329 if params is not None: 3330 existing_cols = pipe.get_columns_types(debug=debug) 3331 valid_params = {k: v for k, v in params.items() if k in existing_cols} 3332 clear_query = ( 3333 f"DELETE FROM {pipe_name}\nWHERE 1 = 1\n" 3334 + ('\n AND ' + build_where(valid_params, self, with_where=False) if valid_params else '') 3335 + ( 3336 ( 3337 f'\n AND {dt_name} >= ' 3338 + dateadd_str(self.flavor, 'day', 0, begin, db_type=dt_db_type) 3339 ) 3340 if begin is not None 3341 else '' 3342 ) + ( 3343 ( 3344 f'\n AND {dt_name} < ' 3345 + dateadd_str(self.flavor, 'day', 0, end, db_type=dt_db_type) 3346 ) 3347 if end is not None 3348 else '' 3349 ) 3350 ) 3351 success = self.exec(clear_query, silent=True, debug=debug) is not None 3352 msg = "Success" if success else f"Failed to clear {pipe}." 3353 return success, msg
Delete a pipe's data within a bounded or unbounded interval without dropping the table.
Parameters
- pipe (mrsm.Pipe): The pipe to clear.
- begin (Union[datetime, int, None], default None): Beginning datetime. Inclusive.
- end (Union[datetime, int, None], default None): Ending datetime. Exclusive.
- params (Optional[Dict[str, Any]], default None):
See
meerschaum.utils.sql.build_where.
3996def deduplicate_pipe( 3997 self, 3998 pipe: mrsm.Pipe, 3999 begin: Union[datetime, int, None] = None, 4000 end: Union[datetime, int, None] = None, 4001 params: Optional[Dict[str, Any]] = None, 4002 debug: bool = False, 4003 **kwargs: Any 4004) -> SuccessTuple: 4005 """ 4006 Delete duplicate values within a pipe's table. 4007 4008 Parameters 4009 ---------- 4010 pipe: mrsm.Pipe 4011 The pipe whose table to deduplicate. 4012 4013 begin: Union[datetime, int, None], default None 4014 If provided, only deduplicate values greater than or equal to this value. 4015 4016 end: Union[datetime, int, None], default None 4017 If provided, only deduplicate values less than this value. 4018 4019 params: Optional[Dict[str, Any]], default None 4020 If provided, further limit deduplication to values which match this query dictionary. 4021 4022 debug: bool, default False 4023 Verbosity toggle. 4024 4025 Returns 4026 ------- 4027 A `SuccessTuple` indicating success. 4028 """ 4029 from meerschaum.utils.sql import ( 4030 sql_item_name, 4031 get_rename_table_queries, 4032 DROP_IF_EXISTS_FLAVORS, 4033 get_create_table_query, 4034 format_cte_subquery, 4035 get_null_replacement, 4036 ) 4037 from meerschaum.utils.misc import generate_password, flatten_list 4038 4039 pipe_table_name = sql_item_name(pipe.target, self.flavor, self.get_pipe_schema(pipe)) 4040 4041 if not pipe.exists(debug=debug): 4042 return False, f"Table {pipe_table_name} does not exist." 4043 4044 dt_col = pipe.columns.get('datetime', None) 4045 cols_types = pipe.get_columns_types(debug=debug) 4046 existing_cols = pipe.get_columns_types(debug=debug) 4047 4048 get_rowcount_query = f"SELECT COUNT(*) FROM {pipe_table_name}" 4049 old_rowcount = self.value(get_rowcount_query, debug=debug) 4050 if old_rowcount is None: 4051 return False, f"Failed to get rowcount for table {pipe_table_name}." 4052 4053 ### Non-datetime indices that in fact exist. 4054 indices = [ 4055 col 4056 for key, col in pipe.columns.items() 4057 if col and col != dt_col and col in cols_types 4058 ] 4059 indices_names = [sql_item_name(index_col, self.flavor, None) for index_col in indices] 4060 existing_cols_names = [sql_item_name(col, self.flavor, None) for col in existing_cols] 4061 duplicate_row_number_name = sql_item_name('dup_row_num', self.flavor, None) 4062 previous_row_number_name = sql_item_name('prev_row_num', self.flavor, None) 4063 4064 index_list_str = ( 4065 sql_item_name(dt_col, self.flavor, None) 4066 if dt_col 4067 else '' 4068 ) 4069 index_list_str_ordered = ( 4070 ( 4071 sql_item_name(dt_col, self.flavor, None) + " DESC" 4072 ) 4073 if dt_col 4074 else '' 4075 ) 4076 if indices: 4077 index_list_str += ', ' + ', '.join(indices_names) 4078 index_list_str_ordered += ', ' + ', '.join(indices_names) 4079 if index_list_str.startswith(','): 4080 index_list_str = index_list_str.lstrip(',').lstrip() 4081 if index_list_str_ordered.startswith(','): 4082 index_list_str_ordered = index_list_str_ordered.lstrip(',').lstrip() 4083 4084 cols_list_str = ', '.join(existing_cols_names) 4085 4086 try: 4087 ### NOTE: MySQL 5 and below does not support window functions (ROW_NUMBER()). 4088 is_old_mysql = ( 4089 self.flavor in ('mysql', 'mariadb') 4090 and 4091 int(self.db_version.split('.')[0]) < 8 4092 ) 4093 except Exception: 4094 is_old_mysql = False 4095 4096 src_query = f""" 4097 SELECT 4098 {cols_list_str}, 4099 ROW_NUMBER() OVER ( 4100 PARTITION BY 4101 {index_list_str} 4102 ORDER BY {index_list_str_ordered} 4103 ) AS {duplicate_row_number_name} 4104 FROM {pipe_table_name} 4105 """ 4106 duplicates_cte_subquery = format_cte_subquery( 4107 src_query, 4108 self.flavor, 4109 sub_name = 'src', 4110 cols_to_select = cols_list_str, 4111 ) + f""" 4112 WHERE {duplicate_row_number_name} = 1 4113 """ 4114 old_mysql_query = ( 4115 f""" 4116 SELECT 4117 {index_list_str} 4118 FROM ( 4119 SELECT 4120 {index_list_str}, 4121 IF( 4122 @{previous_row_number_name} <> {index_list_str.replace(', ', ' + ')}, 4123 @{duplicate_row_number_name} := 0, 4124 @{duplicate_row_number_name} 4125 ), 4126 @{previous_row_number_name} := {index_list_str.replace(', ', ' + ')}, 4127 @{duplicate_row_number_name} := @{duplicate_row_number_name} + 1 AS """ 4128 + f"""{duplicate_row_number_name} 4129 FROM 4130 {pipe_table_name}, 4131 ( 4132 SELECT @{duplicate_row_number_name} := 0 4133 ) AS {duplicate_row_number_name}, 4134 ( 4135 SELECT @{previous_row_number_name} := '{get_null_replacement('str', 'mysql')}' 4136 ) AS {previous_row_number_name} 4137 ORDER BY {index_list_str_ordered} 4138 ) AS t 4139 WHERE {duplicate_row_number_name} = 1 4140 """ 4141 ) 4142 if is_old_mysql: 4143 duplicates_cte_subquery = old_mysql_query 4144 4145 session_id = generate_password(3) 4146 4147 dedup_table = self.get_temporary_target(pipe.target, transact_id=session_id, label='dedup') 4148 temp_old_table = self.get_temporary_target(pipe.target, transact_id=session_id, label='old') 4149 temp_old_table_name = sql_item_name(temp_old_table, self.flavor, self.get_pipe_schema(pipe)) 4150 4151 create_temporary_table_query = get_create_table_query( 4152 duplicates_cte_subquery, 4153 dedup_table, 4154 self.flavor, 4155 ) + f""" 4156 ORDER BY {index_list_str_ordered} 4157 """ 4158 if_exists_str = "IF EXISTS" if self.flavor in DROP_IF_EXISTS_FLAVORS else "" 4159 alter_queries = flatten_list([ 4160 get_rename_table_queries( 4161 pipe.target, 4162 temp_old_table, 4163 self.flavor, 4164 schema=self.get_pipe_schema(pipe), 4165 ), 4166 get_rename_table_queries( 4167 dedup_table, 4168 pipe.target, 4169 self.flavor, 4170 schema=None, 4171 new_schema=self.get_pipe_schema(pipe), 4172 ), 4173 f"DROP TABLE {if_exists_str} {temp_old_table_name}", 4174 ]) 4175 4176 self._log_temporary_tables_creation(temp_old_table, create=(not pipe.temporary), debug=debug) 4177 create_temporary_result = self.execute(create_temporary_table_query, debug=debug) 4178 if create_temporary_result is None: 4179 return False, f"Failed to deduplicate table {pipe_table_name}." 4180 4181 results = self.exec_queries( 4182 alter_queries, 4183 break_on_error=True, 4184 rollback=True, 4185 debug=debug, 4186 ) 4187 4188 fail_query = None 4189 for result, query in zip(results, alter_queries): 4190 if result is None: 4191 fail_query = query 4192 break 4193 success = fail_query is None 4194 4195 new_rowcount = ( 4196 self.value(get_rowcount_query, debug=debug) 4197 if success 4198 else None 4199 ) 4200 4201 msg = ( 4202 ( 4203 f"Successfully deduplicated table {pipe_table_name}" 4204 + ( 4205 f"\nfrom {old_rowcount:,} to {new_rowcount:,} rows" 4206 if old_rowcount != new_rowcount 4207 else '' 4208 ) + '.' 4209 ) 4210 if success 4211 else f"Failed to execute query:\n{fail_query}" 4212 ) 4213 return success, msg
Delete duplicate values within a pipe's table.
Parameters
- pipe (mrsm.Pipe): The pipe whose table to deduplicate.
- begin (Union[datetime, int, None], default None): If provided, only deduplicate values greater than or equal to this value.
- end (Union[datetime, int, None], default None): If provided, only deduplicate values less than this value.
- params (Optional[Dict[str, Any]], default None): If provided, further limit deduplication to values which match this query dictionary.
- debug (bool, default False): Verbosity toggle.
Returns
- A
SuccessTupleindicating success.
3356def get_pipe_table( 3357 self, 3358 pipe: mrsm.Pipe, 3359 debug: bool = False, 3360) -> Union['sqlalchemy.Table', None]: 3361 """ 3362 Return the `sqlalchemy.Table` object for a `mrsm.Pipe`. 3363 3364 Parameters 3365 ---------- 3366 pipe: mrsm.Pipe: 3367 The pipe in question. 3368 3369 Returns 3370 ------- 3371 A `sqlalchemy.Table` object. 3372 3373 """ 3374 from meerschaum.utils.sql import get_sqlalchemy_table 3375 if not pipe.exists(debug=debug): 3376 return None 3377 3378 return get_sqlalchemy_table( 3379 pipe.target, 3380 connector=self, 3381 schema=self.get_pipe_schema(pipe), 3382 debug=debug, 3383 refresh=True, 3384 )
Return the sqlalchemy.Table object for a mrsm.Pipe.
Parameters
- pipe (mrsm.Pipe:): The pipe in question.
Returns
- A
sqlalchemy.Tableobject.
3387def get_pipe_columns_types( 3388 self, 3389 pipe: mrsm.Pipe, 3390 debug: bool = False, 3391) -> Dict[str, str]: 3392 """ 3393 Get the pipe's columns and types. 3394 3395 Parameters 3396 ---------- 3397 pipe: mrsm.Pipe: 3398 The pipe to get the columns for. 3399 3400 Returns 3401 ------- 3402 A dictionary of columns names (`str`) and types (`str`). 3403 3404 Examples 3405 -------- 3406 >>> conn.get_pipe_columns_types(pipe) 3407 { 3408 'dt': 'TIMESTAMP WITHOUT TIMEZONE', 3409 'id': 'BIGINT', 3410 'val': 'DOUBLE PRECISION', 3411 } 3412 >>> 3413 """ 3414 from meerschaum.utils.sql import get_table_cols_types 3415 if not pipe.exists(debug=debug): 3416 return {} 3417 3418 if self.flavor not in ('oracle', 'mysql', 'mariadb', 'sqlite', 'geopackage'): 3419 return get_table_cols_types( 3420 pipe.target, 3421 self, 3422 flavor=self.flavor, 3423 schema=self.get_pipe_schema(pipe), 3424 debug=debug, 3425 ) 3426 3427 if debug: 3428 dprint(f"Fetching columns_types for {pipe} with via SQLAlchemy table.") 3429 3430 table_columns = {} 3431 try: 3432 pipe_table = self.get_pipe_table(pipe, debug=debug) 3433 if pipe_table is None: 3434 return {} 3435 3436 if debug: 3437 dprint("Found columns:") 3438 mrsm.pprint(dict(pipe_table.columns)) 3439 3440 for col in pipe_table.columns: 3441 table_columns[str(col.name)] = str(col.type) 3442 except Exception as e: 3443 traceback.print_exc() 3444 warn(e) 3445 table_columns = {} 3446 3447 return table_columns
Get the pipe's columns and types.
Parameters
- pipe (mrsm.Pipe:): The pipe to get the columns for.
Returns
- A dictionary of columns names (
str) and types (str).
Examples
>>> conn.get_pipe_columns_types(pipe)
{
'dt': 'TIMESTAMP WITHOUT TIMEZONE',
'id': 'BIGINT',
'val': 'DOUBLE PRECISION',
}
>>>
3942def get_to_sql_dtype( 3943 self, 3944 pipe: 'mrsm.Pipe', 3945 df: 'pd.DataFrame', 3946 update_dtypes: bool = True, 3947) -> Dict[str, 'sqlalchemy.sql.visitors.TraversibleType']: 3948 """ 3949 Given a pipe and DataFrame, return the `dtype` dictionary for `to_sql()`. 3950 3951 Parameters 3952 ---------- 3953 pipe: mrsm.Pipe 3954 The pipe which may contain a `dtypes` parameter. 3955 3956 df: pd.DataFrame 3957 The DataFrame to be pushed via `to_sql()`. 3958 3959 update_dtypes: bool, default True 3960 If `True`, patch the pipe's dtypes onto the DataFrame's dtypes. 3961 3962 Returns 3963 ------- 3964 A dictionary with `sqlalchemy` datatypes. 3965 3966 Examples 3967 -------- 3968 >>> import pandas as pd 3969 >>> import meerschaum as mrsm 3970 >>> 3971 >>> conn = mrsm.get_connector('sql:memory') 3972 >>> df = pd.DataFrame([{'a': {'b': 1}}]) 3973 >>> pipe = mrsm.Pipe('a', 'b', dtypes={'a': 'json'}) 3974 >>> get_to_sql_dtype(pipe, df) 3975 {'a': <class 'sqlalchemy.sql.sqltypes.JSON'>} 3976 """ 3977 from meerschaum.utils.dataframe import get_special_cols 3978 from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type 3979 df_dtypes = { 3980 col: str(typ) 3981 for col, typ in df.dtypes.items() 3982 } 3983 special_cols = get_special_cols(df) 3984 df_dtypes.update(special_cols) 3985 3986 if update_dtypes: 3987 df_dtypes.update(pipe.dtypes) 3988 3989 return { 3990 col: get_db_type_from_pd_type(typ, self.flavor, as_sqlalchemy=True) 3991 for col, typ in df_dtypes.items() 3992 if col and typ 3993 }
Given a pipe and DataFrame, return the dtype dictionary for to_sql().
Parameters
- pipe (mrsm.Pipe):
The pipe which may contain a
dtypesparameter. - 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
sqlalchemydatatypes.
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'>}
4216def get_pipe_schema(self, pipe: mrsm.Pipe) -> Union[str, None]: 4217 """ 4218 Return the schema to use for this pipe. 4219 First check `pipe.parameters['schema']`, then check `self.schema`. 4220 4221 Parameters 4222 ---------- 4223 pipe: mrsm.Pipe 4224 The pipe which may contain a configured schema. 4225 4226 Returns 4227 ------- 4228 A schema string or `None` if nothing is configured. 4229 """ 4230 if self.flavor in ('sqlite', 'geopackage'): 4231 return self.schema 4232 return pipe.parameters.get('schema', self.schema)
Return the schema to use for this pipe.
First check pipe.parameters['schema'], then check self.schema.
Parameters
- pipe (mrsm.Pipe): The pipe which may contain a configured schema.
Returns
- A schema string or
Noneif nothing is configured.
1732def create_pipe_table_from_df( 1733 self, 1734 pipe: mrsm.Pipe, 1735 df: 'pd.DataFrame', 1736 debug: bool = False, 1737) -> mrsm.SuccessTuple: 1738 """ 1739 Create a pipe's table from its configured dtypes and an incoming dataframe. 1740 """ 1741 from meerschaum.utils.dataframe import get_special_cols 1742 from meerschaum.utils.sql import ( 1743 get_create_table_queries, 1744 sql_item_name, 1745 get_create_schema_if_not_exists_queries, 1746 ) 1747 from meerschaum.utils.dtypes.sql import get_db_type_from_pd_type 1748 if self.flavor == 'geopackage': 1749 init_success, init_msg = self._init_geopackage_pipe(df, pipe, debug=debug) 1750 if not init_success: 1751 return init_success, init_msg 1752 1753 primary_key = pipe.columns.get('primary', None) 1754 primary_key_typ = ( 1755 pipe.dtypes.get(primary_key, str(df.dtypes.get(primary_key, 'int'))) 1756 if primary_key 1757 else None 1758 ) 1759 primary_key_db_type = ( 1760 get_db_type_from_pd_type(primary_key_typ, self.flavor) 1761 if primary_key 1762 else None 1763 ) 1764 dt_col = pipe.columns.get('datetime', None) 1765 new_dtypes = { 1766 **{ 1767 col: str(typ) 1768 for col, typ in df.dtypes.items() 1769 }, 1770 **{ 1771 col: str(df.dtypes.get(col, 'int')) 1772 for col_ix, col in pipe.columns.items() 1773 if col and col_ix != 'primary' 1774 }, 1775 **get_special_cols(df), 1776 **pipe.dtypes 1777 } 1778 autoincrement = ( 1779 pipe.parameters.get('autoincrement', False) 1780 or (primary_key and primary_key not in new_dtypes) 1781 ) 1782 if autoincrement: 1783 _ = new_dtypes.pop(primary_key, None) 1784 1785 schema = self.get_pipe_schema(pipe) 1786 1787 ### When supported (TimescaleDB 2.21+), create the hypertable declaratively via 1788 ### `CREATE TABLE ... WITH (tsdb.hypertable, ...)`. Fall back to a plain `CREATE TABLE` 1789 ### (and the `create_hypertable()` call in `get_create_index_queries`) if it fails. 1790 hypertable = ( 1791 self.flavor in ('timescaledb', 'timescaledb-ha') 1792 and pipe.parameters.get('hypertable', True) 1793 and dt_col is not None 1794 ) 1795 1796 ### Use the declarative `CREATE TABLE ... WITH (tsdb.hypertable, ...)` path only when Hypercore 1797 ### is enabled (the default). Declarative creation enables the columnstore (via the 1798 ### `segmentby`/`orderby` options) AND makes TimescaleDB auto-install a columnstore policy — 1799 ### exactly the Hypercore behavior we want. With `hypercore=False`, fall back to a plain table 1800 ### plus the `create_hypertable()` call during index creation, which adds NO columnstore policy, 1801 ### keeping `hypercore` a true opt-out (a plain row-store hypertable). 1802 hypercore = hypertable and pipe.parameters.get('hypercore', True) 1803 hypertable_chunk_interval = None 1804 hypertable_segmentby = None 1805 hypertable_orderby = None 1806 if hypercore: 1807 chunk_interval = pipe.get_chunk_interval(debug=debug) 1808 hypertable_chunk_interval = ( 1809 f'{chunk_interval}' 1810 if isinstance(chunk_interval, int) 1811 else f'{int(chunk_interval.total_seconds() / 60)} minutes' 1812 ) 1813 _compress_settings = self._get_compress_settings(pipe) 1814 hypertable_segmentby = _compress_settings['segmentby'] or None 1815 hypertable_orderby = _compress_settings['orderby'] or None 1816 1817 ### Native range partitioning (non-TimescaleDB flavors); a no-op column for others. 1818 partition_by_column = self._get_partition_column(pipe) 1819 ### MySQL/MariaDB require the initial partitions declared inline at `CREATE TABLE` 1820 ### (an empty RANGE-partitioned table is invalid); compute them from the creation df. 1821 partition_bounds = ( 1822 self._get_initial_partition_bounds(pipe, df, debug=debug) 1823 if (partition_by_column is not None and self.flavor in ('mysql', 'mariadb')) 1824 else None 1825 ) 1826 ### A MySQL RANGE table needs at least one inline partition; if the creation df has no 1827 ### datetime values, fall back to a plain table (rare — `is_new` normally implies rows). 1828 if partition_by_column is not None and self.flavor in ('mysql', 'mariadb') and not partition_bounds: 1829 partition_by_column = None 1830 1831 ### MSSQL partitions via a function + scheme created before the table; its clustered index is 1832 ### placed on the scheme (passed as `partition_scheme_name`). 1833 partition_scheme_name = None 1834 partition_creation_queries = [] 1835 if partition_by_column is not None and self.flavor == 'mssql': 1836 partition_scheme_name = self._partition_scheme_name(pipe) 1837 partition_creation_queries = self._get_mssql_partition_creation_queries( 1838 pipe, df, debug=debug 1839 ) 1840 1841 def _build_create_table_queries(_hypertable_chunk_interval): 1842 _queries = get_create_table_queries( 1843 new_dtypes, 1844 pipe.target, 1845 self.flavor, 1846 schema=schema, 1847 primary_key=primary_key, 1848 primary_key_db_type=primary_key_db_type, 1849 datetime_column=dt_col, 1850 hypertable_chunk_interval=_hypertable_chunk_interval, 1851 hypertable_segmentby=(hypertable_segmentby if _hypertable_chunk_interval else None), 1852 hypertable_orderby=(hypertable_orderby if _hypertable_chunk_interval else None), 1853 partition_by_column=partition_by_column, 1854 partition_bounds=partition_bounds, 1855 partition_scheme_name=partition_scheme_name, 1856 ) 1857 if partition_creation_queries: 1858 _queries = partition_creation_queries + _queries 1859 if schema: 1860 _queries = ( 1861 get_create_schema_if_not_exists_queries(schema, self.flavor) 1862 + _queries 1863 ) 1864 return _queries 1865 1866 create_table_queries = _build_create_table_queries(hypertable_chunk_interval) 1867 success = all( 1868 self.exec_queries( 1869 create_table_queries, 1870 break_on_error=True, 1871 rollback=True, 1872 silent=hypercore, 1873 debug=debug, 1874 ) 1875 ) 1876 if not success and hypercore: 1877 ### Declarative hypertable syntax unsupported; retry as a plain table. 1878 ### `create_hypertable()` runs later during index creation. 1879 create_table_queries = _build_create_table_queries(None) 1880 success = all( 1881 self.exec_queries(create_table_queries, break_on_error=True, rollback=True, debug=debug) 1882 ) 1883 ### Declarative Hypercore creation auto-installs a columnstore policy, which needs an 1884 ### `integer_now` function on an integer axis. 1885 if success and hypercore and not self.set_integer_now_func(pipe, debug=debug): 1886 warn( 1887 f"Could not register an `integer_now` function for {pipe}; " 1888 "its columnstore policy will fail on every run.", 1889 stack=False, 1890 ) 1891 1892 target_name = sql_item_name(pipe.target, schema=self.get_pipe_schema(pipe), flavor=self.flavor) 1893 msg = ( 1894 "Success" 1895 if success 1896 else f"Failed to create {target_name}." 1897 ) 1898 if success and self.flavor == 'geopackage': 1899 return self._init_geopackage_pipe(df, pipe, debug=debug) 1900 1901 return success, msg
Create a pipe's table from its configured dtypes and an incoming dataframe.
3450def get_pipe_columns_indices( 3451 self, 3452 pipe: mrsm.Pipe, 3453 debug: bool = False, 3454) -> Dict[str, List[Dict[str, str]]]: 3455 """ 3456 Return a dictionary mapping columns to the indices created on those columns. 3457 3458 Parameters 3459 ---------- 3460 pipe: mrsm.Pipe 3461 The pipe to be queried against. 3462 3463 Returns 3464 ------- 3465 A dictionary mapping columns names to lists of dictionaries. 3466 The dictionaries in the lists contain the name and type of the indices. 3467 """ 3468 if pipe.__dict__.get('_skip_check_indices', False): 3469 return {} 3470 3471 from meerschaum.utils.sql import get_table_cols_indices 3472 return get_table_cols_indices( 3473 pipe.target, 3474 self, 3475 flavor=self.flavor, 3476 schema=self.get_pipe_schema(pipe), 3477 debug=debug, 3478 )
Return a dictionary mapping columns to the indices created on those columns.
Parameters
- pipe (mrsm.Pipe): The pipe to be queried against.
Returns
- A dictionary mapping columns names to lists of dictionaries.
- The dictionaries in the lists contain the name and type of the indices.
4235@staticmethod 4236def get_temporary_target( 4237 target: str, 4238 transact_id: Optional[str] = None, 4239 label: Optional[str] = None, 4240 separator: Optional[str] = None, 4241) -> str: 4242 """ 4243 Return a unique(ish) temporary target for a pipe. 4244 """ 4245 from meerschaum.utils.misc import generate_password 4246 temp_target_cf = ( 4247 mrsm.get_config('system', 'connectors', 'sql', 'instance', 'temporary_target') or {} 4248 ) 4249 transaction_id_len = temp_target_cf.get('transaction_id_length', 3) 4250 transact_id = transact_id or generate_password(transaction_id_len) 4251 temp_prefix = temp_target_cf.get('prefix', '_') 4252 separator = separator or temp_target_cf.get('separator', '_') 4253 return ( 4254 temp_prefix 4255 + target 4256 + separator 4257 + transact_id 4258 + ((separator + label) if label else '') 4259 )
Return a unique(ish) temporary target for a pipe.
366def create_pipe_indices( 367 self, 368 pipe: mrsm.Pipe, 369 columns: Optional[List[str]] = None, 370 debug: bool = False, 371) -> SuccessTuple: 372 """ 373 Create a pipe's indices. 374 """ 375 success = self.create_indices(pipe, columns=columns, debug=debug) 376 msg = ( 377 "Success" 378 if success 379 else f"Failed to create indices for {pipe}." 380 ) 381 return success, msg
Create a pipe's indices.
422def drop_pipe_indices( 423 self, 424 pipe: mrsm.Pipe, 425 columns: Optional[List[str]] = None, 426 debug: bool = False, 427) -> SuccessTuple: 428 """ 429 Drop a pipe's indices. 430 """ 431 success = self.drop_indices(pipe, columns=columns, debug=debug) 432 msg = ( 433 "Success" 434 if success 435 else f"Failed to drop indices for {pipe}." 436 ) 437 return success, msg
Drop a pipe's indices.
474def get_pipe_index_names(self, pipe: mrsm.Pipe) -> Dict[str, str]: 475 """ 476 Return a dictionary mapping index keys to their names on the database. 477 478 Returns 479 ------- 480 A dictionary of index keys to column names. 481 """ 482 from meerschaum.utils.sql import DEFAULT_SCHEMA_FLAVORS, truncate_item_name 483 _parameters = pipe.parameters 484 _index_template = _parameters.get('index_template', "IX_{schema_str}{target}_{column_names}") 485 _schema = self.get_pipe_schema(pipe) 486 if _schema is None: 487 _schema = ( 488 DEFAULT_SCHEMA_FLAVORS.get(self.flavor, None) 489 if self.flavor != 'mssql' 490 else None 491 ) 492 schema_str = '' if _schema is None else f'{_schema}_' 493 schema_str = '' 494 _indices = pipe.indices 495 _target = pipe.target 496 _column_names = { 497 ix: ( 498 '_'.join(cols) 499 if isinstance(cols, (list, tuple)) 500 else str(cols) 501 ) 502 for ix, cols in _indices.items() 503 if cols 504 } 505 _index_names = { 506 ix: _index_template.format( 507 target=_target, 508 column_names=column_names, 509 connector_keys=pipe.connector_keys, 510 metric_key=pipe.metric_key, 511 location_key=pipe.location_key, 512 schema_str=schema_str, 513 ) 514 for ix, column_names in _column_names.items() 515 } 516 ### NOTE: Skip any duplicate indices. 517 seen_index_names = {} 518 for ix, index_name in _index_names.items(): 519 if index_name in seen_index_names: 520 continue 521 seen_index_names[index_name] = ix 522 return { 523 ix: truncate_item_name(index_name, flavor=self.flavor) 524 for index_name, ix in seen_index_names.items() 525 }
Return a dictionary mapping index keys to their names on the database.
Returns
- A dictionary of index keys to column names.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 newSQLConnector, otherwise create a new object.
Returns
- A new SQLConnector object or a dictionary of attributes (if
as_dictisTrue).
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'}
>>>