Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ public enum SQLStateChangingFunctions {
return names.contains(lowered)
}

static func isDuckDBCall(_ name: String) -> Bool {
duckDBCalls.contains(name.uppercased().lowercased())
}

// SQLite runs a table-valued pragma named as a table source too, so `SELECT * FROM main.pragma_optimize` runs
// ANALYZE. A call is the call scanner's; a column or alias of that name runs nothing.
static func namesStateChangingTable(in text: NSString, tokens: [TSQLTokens.Token], grammar: SQLLexicalGrammar) -> Bool {
Expand Down Expand Up @@ -72,21 +76,29 @@ public enum SQLStateChangingFunctions {
.union(catalogAndStorage)
.union(indexMaintenance)
.union(sqlTextRunners)
.union(scheduledJobs)
.union(hypertablesAndChunks)
.union(rolesAndGrants)

static let serverSignals: Set<String> = [
"pg_cancel_backend", "pg_terminate_backend", "pg_reload_conf", "pg_rotate_logfile",
"pg_log_backend_memory_contexts"
]

// dblink_close commits the remote transaction dblink_open began, with whatever dblink_exec ran in it, and
// pg_background's cancels stop a worker before it commits.
static let sessionControl: Set<String> = [
"system$abort_session", "system$cancel_query", "system$cancel_all_queries", "system$abort_transaction",
"abortsessions"
"abortsessions", "dblink_cancel_query", "dblink_close", "pg_background_cancel", "pg_background_cancel_v2",
"pg_background_cancel_all", "pg_background_cancel_all_v2", "pg_background_cancel_by_label",
"pg_background_cancel_by_label_v2", "pg_background_cancel_v2_grace"
]

static let recoveryAndBackup: Set<String> = [
"pg_promote", "pg_wal_replay_pause", "pg_wal_replay_resume", "pg_backup_start", "pg_backup_stop",
"pg_switch_wal", "pg_create_restore_point", "pg_log_standby_snapshot", "pg_start_backup", "pg_stop_backup",
"pg_switch_xlog", "pg_xlog_replay_pause", "pg_xlog_replay_resume"
"pg_switch_xlog", "pg_xlog_replay_pause", "pg_xlog_replay_resume", "timescaledb_pre_restore",
"timescaledb_post_restore"
]

static let replication: Set<String> = [
Expand Down Expand Up @@ -138,10 +150,48 @@ public enum SQLStateChangingFunctions {

// SQL Server's OPENQUERY and OPENROWSET hand their last argument to a linked server, or back to this one through
// a loopback link, as a batch of its own: a SELECT from either can carry a DELETE, as a SELECT from dblink can.
// crosstab, connectby and xpath_table paste their text arguments into the SQL they run.
// crosstab, connectby and xpath_table paste their text arguments into the SQL they run. pg_background runs its
// text in a worker that commits on its own, and pg_cron's schedule, schedule_in_database and alter_job store a
// command its worker runs later; TimescaleDB's alter_job shares the name.
static let sqlTextRunners: Set<String> = [
"query_to_xml", "query_to_xml_and_xmlschema", "ts_stat", "ts_rewrite", "dblink", "dblink_exec",
"dblink_send_query", "dblink_open", "openquery", "openrowset", "crosstab", "crosstab2", "crosstab3", "crosstab4",
"connectby", "xpath_table"
"connectby", "xpath_table", "pg_background_launch", "pg_background_launch_v2", "pg_background_submit",
"pg_background_submit_v2", "pg_background_run", "pg_background_run_v2", "pg_background_run_query",
"pg_background_run_query_v2", "schedule", "schedule_in_database", "alter_job", "subscription_exec"
]

// pg_cron's unschedule, and TimescaleDB's jobs and the policies that add or remove one.
static let scheduledJobs: Set<String> = [
"unschedule", "add_job", "delete_job", "add_retention_policy", "remove_retention_policy", "add_reorder_policy",
"remove_reorder_policy", "add_compaction_policy", "remove_compaction_policy", "add_compression_policy",
"remove_compression_policy", "add_continuous_aggregate_policy", "remove_continuous_aggregate_policy",
"add_policies", "alter_policies", "remove_policies", "remove_all_policies"
]

// TimescaleDB's functions that change a hypertable, or drop, move or rewrite its chunks. The data node ones are
// gone since 2.14, and set_adaptive_chunking since 2.28, but older servers still have them.
static let hypertablesAndChunks: Set<String> = [
"create_hypertable", "add_dimension", "set_chunk_time_interval", "set_partitioning_interval",
"set_number_partitions", "set_integer_now_func", "set_adaptive_chunking", "enable_chunk_skipping",
"disable_chunk_skipping", "attach_tablespace", "detach_tablespace", "detach_tablespaces", "drop_chunks",
"drop_chunk", "create_chunk", "compress_chunk", "decompress_chunk", "reorder_chunk", "move_chunk",
"block_new_chunks", "allow_new_chunks", "create_distributed_hypertable", "add_data_node", "attach_data_node",
"detach_data_node", "delete_data_node", "alter_data_node", "set_replication_factor",
"create_distributed_restore_point"
]

static let rolesAndGrants: Set<String> = [
"pg_background_grant_privileges", "pg_background_revoke_privileges", "grant_pg_background_privileges",
"revoke_pg_background_privileges", "pg_background_drop_executor_role"
]

// DuckDB's query() and json_execute_serialized_sql() run the SELECT in their text, which can take a value from a
// sequence; the second does so outside a read-only transaction (both measured on 2.0.0). The postgres, mysql and
// sqlite extensions' query and execute functions run any statement on an attached database, and dbgen and dsdgen
// create and fill tables. Elsewhere `query` is any function's name.
static let duckDBCalls: Set<String> = [
"query", "json_execute_serialized_sql", "postgres_query", "postgres_execute", "mysql_query", "mysql_execute",
"sqlite_query", "dbgen", "dsdgen"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,17 +158,6 @@ extension SQLStatementTiering {
}
}

// DuckDB's query() and json_execute_serialized_sql() run the SELECT in their text, which can take a value from a
// sequence; the second does so outside a read-only transaction (both measured on 2.0.0). The postgres, mysql and
// sqlite extensions' query and execute functions run any statement on an attached database, and dbgen and dsdgen
// create and fill tables.
static let stateChangingCalls: [String: Set<String>] = [
duckDB: [
"QUERY", "JSON_EXECUTE_SERIALIZED_SQL", "POSTGRES_QUERY", "POSTGRES_EXECUTE", "MYSQL_QUERY", "MYSQL_EXECUTE",
"SQLITE_QUERY", "DBGEN", "DSDGEN",
],
]

// An EXPLAIN that runs its statement is tiered by that statement's own calls.
func stateChangingCallTier(
_ code: StatementCode,
Expand All @@ -177,10 +166,10 @@ extension SQLStatementTiering {
) -> StatementTier {
guard !SQLExplainStatement.keywords.contains(code.keyword) else { return .safe }
if advancesSequence(code) { return .write }
let engineCalls = Self.stateChangingCalls[databaseTypeId] ?? []
let changesState = calls.contains { call in
guard case .named(let name, _) = call.callee else { return true }
return SQLStateChangingFunctions.contains(name) || engineCalls.contains(name.uppercased())
return SQLStateChangingFunctions.contains(name)
|| (databaseTypeId == Self.duckDB && SQLStateChangingFunctions.isDuckDBCall(name))
}
guard !changesState else { return .write }
let namesTable = SQLStateChangingFunctions.namesStateChangingTable(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,21 @@ struct SQLStateChangingFunctionsTests {
"files_and_extensions": SQLStateChangingFunctions.filesAndExtensions,
"catalog_and_storage": SQLStateChangingFunctions.catalogAndStorage,
"index_maintenance": SQLStateChangingFunctions.indexMaintenance,
"sql_text_runners": SQLStateChangingFunctions.sqlTextRunners
"sql_text_runners": SQLStateChangingFunctions.sqlTextRunners,
"scheduled_jobs": SQLStateChangingFunctions.scheduledJobs,
"hypertables_and_chunks": SQLStateChangingFunctions.hypertablesAndChunks,
"roles_and_grants": SQLStateChangingFunctions.rolesAndGrants,
"duckdb_calls": SQLStateChangingFunctions.duckDBCalls
]

@Test("Each family lists the names the Linux app lists")
func familiesMatchTheSharedNames() throws {
let data = try Data(contentsOf: Self.sharedNames)
let shared = try JSONDecoder().decode([String: [String]].self, from: data)
let sharedFamilies = shared.mapValues { Set($0) }
let everyName = Set(shared.values.joined())
let everyEngine = Set(shared.filter { $0.key != "duckdb_calls" }.values.joined())
#expect(Self.families == sharedFamilies)
#expect(SQLStateChangingFunctions.names == everyName)
#expect(SQLStateChangingFunctions.names == everyEngine)
}

@Test("A listed name matches in any case, under sys_, and with a letter MariaDB folds", arguments: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,39 @@ struct SQLStatementClassifierTests {
#expect(Self.tier(sql, engine) == .safe)
}

@Test("An extension function that runs SQL, changes a job or a chunk, or stops a worker is a write", arguments: [
"SELECT pg_background_launch('SELECT 1')",
"SELECT * FROM pg_background_run_query_v2('SELECT 1') AS r(n int)",
"SELECT pg_background_cancel_all()",
"SELECT pg_background_grant_privileges('app')",
"SELECT cron.schedule('0 3 * * *', 'VACUUM orders')",
"SELECT cron.unschedule('nightly')",
"SELECT drop_chunks('conditions', INTERVAL '3 months')",
"SELECT compress_chunk(c) FROM show_chunks('conditions') c",
"SELECT create_hypertable('conditions', by_range('time'))",
"SELECT add_retention_policy('conditions', INTERVAL '1 year')",
"SELECT alter_job(1000, schedule_interval => INTERVAL '1 day')",
"SELECT timescaledb_experimental.remove_all_policies('conditions')",
"SELECT timescaledb_pre_restore()",
"SELECT dblink_close('app', 'c')",
"SELECT dblink_cancel_query('app')",
])
func extensionFunctionWrites(sql: String) {
#expect(Self.tier(sql, "PostgreSQL") == .write)
}

@Test("An extension function that only reads stays a read", arguments: [
"SELECT * FROM pg_background_result(123) AS r(n int)",
"SELECT pg_background_detach(123)",
"SELECT show_chunks('conditions')",
"SELECT * FROM cron.job",
"SELECT dblink_connect('app', 'dbname=shop')",
"SELECT schedule FROM jobs",
])
func extensionReadStaysSafe(sql: String) {
#expect(Self.tier(sql, "PostgreSQL") == .safe)
}

@Test("SurrealQL's REMOVE drops what it names, wherever the statement stands", arguments: [
"REMOVE TABLE person",
"remove table if exists person;",
Expand Down
1 change: 1 addition & 0 deletions apple/changelog.d/4043.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Read-Only and Safe Mode treated pg_background, pg_cron and TimescaleDB calls, drop_chunks included, as reads.
Loading
Loading