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