sql-data-guard 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {sql_data_guard-0.2.0/src/sql_data_guard.egg-info → sql_data_guard-0.2.1}/PKG-INFO +1 -1
  2. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/pyproject.toml +1 -1
  3. sql_data_guard-0.2.1/src/sql_data_guard/__init__.py +4 -0
  4. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/config_suggestion.py +46 -12
  5. sql_data_guard-0.2.1/src/sql_data_guard/restriction_validation.py +260 -0
  6. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/restriction_verification.py +108 -37
  7. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/sql_data_guard.py +142 -23
  8. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/verification_utils.py +5 -1
  9. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1/src/sql_data_guard.egg-info}/PKG-INFO +1 -1
  10. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard.egg-info/SOURCES.txt +2 -0
  11. sql_data_guard-0.2.1/test/test_bypass_policies_unit.py +779 -0
  12. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_bypass_unit.py +68 -19
  13. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_cte_scope_unit.py +22 -0
  14. sql_data_guard-0.2.1/test/test_database_policy_unit.py +346 -0
  15. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_function_policy_unit.py +31 -0
  16. sql_data_guard-0.2.1/test/test_sql_guard_validation_unit.py +347 -0
  17. sql_data_guard-0.2.0/src/sql_data_guard/__init__.py +0 -4
  18. sql_data_guard-0.2.0/src/sql_data_guard/restriction_validation.py +0 -121
  19. sql_data_guard-0.2.0/test/test_sql_guard_validation_unit.py +0 -177
  20. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/LICENSE.md +0 -0
  21. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/README.md +0 -0
  22. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/setup.cfg +0 -0
  23. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/column_masking.py +0 -0
  24. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/mcpwrapper/mcp_wrapper.py +0 -0
  25. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/rest/__init__.py +0 -0
  26. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/rest/gunicorn.conf.py +0 -0
  27. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/rest/sql_data_guard_rest.py +0 -0
  28. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/verification_context.py +0 -0
  29. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard.egg-info/dependency_links.txt +0 -0
  30. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard.egg-info/requires.txt +0 -0
  31. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard.egg-info/top_level.txt +0 -0
  32. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_column_masking_unit.py +0 -0
  33. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_config_suggestion_unit.py +0 -0
  34. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_correlated_unit.py +0 -0
  35. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_dify_plugin_unit.py +0 -0
  36. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_duckdb_unit.py +0 -0
  37. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_identifier_case_unit.py +0 -0
  38. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_limit_enforcement_unit.py +0 -0
  39. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_mcp_wrapper_unit.py +0 -0
  40. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_rest_api_unit.py +0 -0
  41. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_security_unit.py +0 -0
  42. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_sql_guard_curr_unit.py +0 -0
  43. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_sql_guard_joins_unit.py +0 -0
  44. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_sql_guard_llm.py +0 -0
  45. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_sql_guard_unit.py +0 -0
  46. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_sql_guard_updates_unit.py +0 -0
  47. {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-data-guard
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Safety Layer for LLM Database Interactions
5
5
  Author-email: Imperva - Threat Research Infra <ww.dis.imperva.threat-research-infra@thalesgroup.com>
6
6
  License: MIT License
@@ -9,7 +9,7 @@ package-dir = {"" = "src"}
9
9
  extra-index-url = ["https://pypi.org/simple"]
10
10
  [project]
11
11
  name = "sql-data-guard"
12
- version = "0.2.0"
12
+ version = "0.2.1"
13
13
  dependencies = [
14
14
  # lower bound: oldest version the test-suite passes on; upper bound: next untested major
15
15
  "sqlglot>=25.20.1,<31"
@@ -0,0 +1,4 @@
1
+ from .config_suggestion import merge_config, suggest_config
2
+ from .sql_data_guard import validate_config, verify_sql
3
+
4
+ __all__ = ["verify_sql", "validate_config", "suggest_config", "merge_config"]
@@ -2,8 +2,9 @@
2
2
  Suggesting and applying allow-list changes to a configuration.
3
3
 
4
4
  ``suggest_config`` answers "what would this query need to be allowed?" and ``merge_config``
5
- applies such an answer to a configuration. Both only deal with the table/column allow-list
6
- (``tables``); restrictions, masks, function lists and limits are never touched.
5
+ applies such an answer to a configuration. Both only deal with the allow-lists (``tables`` and
6
+ their columns, and ``databases``); restrictions, masks, function lists, limits and catalogs are
7
+ never touched.
7
8
  """
8
9
 
9
10
  import copy
@@ -43,8 +44,19 @@ def _has_column(entry: dict, column: str) -> bool:
43
44
 
44
45
  def validate_delta(delta: dict) -> None:
45
46
  """Raises ValueError unless ``delta`` has the shape produced by ``suggest_config``."""
46
- if not isinstance(delta, dict) or set(delta) != {"tables"} or not isinstance(delta["tables"], list):
47
- raise ValueError("A config delta must be an object with a single 'tables' list.")
47
+ if (
48
+ not isinstance(delta, dict)
49
+ or "tables" not in delta
50
+ or not set(delta) <= {"tables", "databases"}
51
+ or not isinstance(delta["tables"], list)
52
+ ):
53
+ raise ValueError(
54
+ "A config delta must be an object with a 'tables' list and optionally a 'databases' list."
55
+ )
56
+ if "databases" in delta:
57
+ databases = delta["databases"]
58
+ if not isinstance(databases, list) or not all(isinstance(d, str) and d for d in databases):
59
+ raise ValueError("'databases' must be a list of database names.")
48
60
  for table in delta["tables"]:
49
61
  if not isinstance(table, dict) or not set(table) <= _DELTA_TABLE_KEYS:
50
62
  raise ValueError(f"A delta table may only have the keys {sorted(_DELTA_TABLE_KEYS)}.")
@@ -60,14 +72,21 @@ def validate_delta(delta: dict) -> None:
60
72
 
61
73
  def merge_config(config: dict, delta: dict) -> dict:
62
74
  """
63
- Returns a copy of ``config`` with the tables and columns of ``delta`` added.
75
+ Returns a copy of ``config`` with the tables, columns and databases of ``delta`` added.
64
76
 
65
77
  A delta table with the same name (and database) as a configured one adds its missing columns
66
- to it; any other delta table is appended. Nothing else in the configuration changes, and the
67
- arguments are not modified. Raises ValueError if ``delta`` is malformed.
78
+ to it; any other delta table is appended. Delta databases missing from ``databases`` are
79
+ appended (the list is created if the configuration has none). Nothing else in the
80
+ configuration changes, and the arguments are not modified. Raises ValueError if ``delta`` is
81
+ malformed.
68
82
  """
69
83
  validate_delta(delta)
70
84
  merged = copy.deepcopy(config)
85
+ if delta.get("databases"):
86
+ databases = merged.setdefault("databases", [])
87
+ for database in delta["databases"]:
88
+ if database.lower() not in {d.lower() for d in databases}:
89
+ databases.append(database)
71
90
  tables = merged.setdefault("tables", [])
72
91
  for table in delta["tables"]:
73
92
  existing = next(
@@ -92,12 +111,15 @@ def suggest_config(sql: str, config: dict, dialect: Optional[str] = None) -> Opt
92
111
  """
93
112
  Suggests the smallest allow-list addition that lets ``sql`` run as written.
94
113
 
95
- Returns ``{"tables": [{"table_name", "database_name"?, "columns": [...]}, ...]}``, to be
96
- applied with ``merge_config``; ``{"tables": []}`` if the query needs nothing added. Returns
97
- None when widening the allow-list cannot make the query acceptable:
114
+ Returns ``{"tables": [{"table_name", "database_name"?, "columns": [...]}, ...]}``, plus
115
+ ``"databases": [...]`` when the configuration has a ``databases`` allow-list that lacks a
116
+ database the query uses, to be applied with ``merge_config``; ``{"tables": []}`` if the
117
+ query needs nothing added. Returns None when widening the allow-list cannot make the query
118
+ acceptable:
98
119
 
99
120
  - the query is rejected for another reason (DML/DDL, several statements, a blocked function,
100
- a column that cannot be attributed to one table, ...);
121
+ a column that cannot be attributed to one table, a table of another catalog, an
122
+ unqualified table in strict mode, ...);
101
123
  - it needs a table without naming any column (``SELECT *`` or ``COUNT(*)`` on a table the
102
124
  configuration does not know), because a configured table must list its columns.
103
125
 
@@ -117,6 +139,7 @@ def _suggest(sql: str, config: dict, dialect: Optional[str]) -> Optional[dict]:
117
139
  working = copy.deepcopy(config)
118
140
  tables = working["tables"]
119
141
  delta_tables: dict = {} # identity -> delta table
142
+ delta_databases: List[str] = []
120
143
 
121
144
  def delta_for(entry: dict) -> dict:
122
145
  key = _identity(entry["table_name"], entry.get("database_name"))
@@ -132,6 +155,15 @@ def _suggest(sql: str, config: dict, dialect: Optional[str]) -> Optional[dict]:
132
155
  _verify_sql(sql, working, dialect, violations)
133
156
  progress = False
134
157
  for v in violations:
158
+ if v["type"] == "database":
159
+ known = {d.lower() for d in working["databases"]}
160
+ if v["database_name"].lower() not in known:
161
+ working["databases"].append(v["database_name"])
162
+ delta_databases.append(v["database_name"])
163
+ progress = True
164
+ continue
165
+ if v["type"] not in ("table", "column"):
166
+ return None # e.g. a catalog mismatch: not an allow-list problem
135
167
  if v["table_name"] is None:
136
168
  return None # a column that belongs to no single table: nothing to suggest
137
169
  entry = _find_entry(tables, v["table_name"], v["database_name"])
@@ -161,7 +193,9 @@ def _suggest(sql: str, config: dict, dialect: Optional[str]) -> Optional[dict]:
161
193
  result = [t for t in delta_tables.values() if t["columns"]]
162
194
  if len(result) != len(delta_tables):
163
195
  return None
164
- delta = {"tables": result}
196
+ delta: dict = {"tables": result}
197
+ if delta_databases:
198
+ delta["databases"] = delta_databases
165
199
  merged = merge_config(config, delta)
166
200
  verdict = _verify_sql(sql, merged, dialect)
167
201
  if not verdict["allowed"] and verdict["fixed"] is None:
@@ -0,0 +1,260 @@
1
+ import math
2
+
3
+ import sqlglot
4
+ import sqlglot.expressions as expr
5
+
6
+ from .column_masking import validate_column_masks
7
+
8
+
9
+ class UnsupportedRestrictionError(Exception):
10
+ pass
11
+
12
+
13
+ def _validate_max_length(config: dict):
14
+ if "max_length" not in config:
15
+ return
16
+ value = config["max_length"]
17
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
18
+ raise ValueError(
19
+ f"Invalid 'max_length'. Expected a positive integer. Received: {value!r}"
20
+ )
21
+
22
+
23
+ def _is_scalar(value) -> bool:
24
+ """A restriction value: a string, a bool or a finite number."""
25
+ if isinstance(value, float):
26
+ return math.isfinite(value)
27
+ return isinstance(value, (bool, int, str))
28
+
29
+
30
+ def _is_number(value) -> bool:
31
+ return _is_scalar(value) and not isinstance(value, (bool, str))
32
+
33
+
34
+ def _is_column_name(name) -> bool:
35
+ """A plain (optionally quoted) column name, as it is pasted into the condition that is added."""
36
+ if not isinstance(name, str) or not name.strip():
37
+ return False
38
+ try:
39
+ statements = sqlglot.parse(name)
40
+ except sqlglot.errors.SqlglotError:
41
+ return False
42
+ if len(statements) != 1: # `id; DROP TABLE x`
43
+ return False
44
+ parsed = statements[0]
45
+ return isinstance(parsed, expr.Column) and not parsed.table
46
+
47
+
48
+ def _validate_columns(table: dict):
49
+ columns = table.get("columns")
50
+ if not isinstance(columns, list) or not columns:
51
+ raise ValueError(
52
+ "Each table must have a 'columns' key with valid column definitions."
53
+ )
54
+ if not all(isinstance(c, str) and c.strip() for c in columns):
55
+ raise ValueError(
56
+ f"The 'columns' of table '{table['table_name']}' must be non-empty strings."
57
+ )
58
+
59
+
60
+ def _validate_restriction_shape(restriction, table_name: str):
61
+ """Checks the keys every restriction needs, whatever its operation."""
62
+ if not isinstance(restriction, dict):
63
+ raise ValueError(f"Each restriction of table '{table_name}' must be an object.")
64
+ if not _is_column_name(restriction.get("column")):
65
+ raise ValueError(
66
+ f"Each restriction of table '{table_name}' must have a 'column' with a column name. "
67
+ f"Received: {restriction.get('column')!r}"
68
+ )
69
+ operation = restriction.get("operation")
70
+ if operation is not None and not isinstance(operation, str):
71
+ raise UnsupportedRestrictionError(
72
+ f"Invalid restriction: 'operation={operation!r}' is not supported."
73
+ )
74
+ if operation in (None, "="):
75
+ # `value`, or `values` (the column may then equal any of them)
76
+ if "value" in restriction:
77
+ ok = _is_scalar(restriction["value"])
78
+ else:
79
+ values = restriction.get("values")
80
+ ok = (
81
+ isinstance(values, list)
82
+ and len(values) > 0
83
+ and all(_is_scalar(v) for v in values)
84
+ )
85
+ if not ok:
86
+ raise ValueError(
87
+ f"Invalid restriction for column '{restriction['column']}' in table '{table_name}'. "
88
+ "Expected a 'value' (a number, string or boolean) or a non-empty list of 'values'."
89
+ )
90
+
91
+
92
+ def _non_empty_string(value) -> bool:
93
+ return isinstance(value, str) and bool(value.strip())
94
+
95
+
96
+ def _validate_database_options(config: dict):
97
+ """`databases`, `strict_database` and `catalog_name` (top level and per table)."""
98
+ if not isinstance(config.get("strict_database", False), bool):
99
+ raise ValueError(
100
+ "Invalid 'strict_database'. Expected true or false. "
101
+ f"Received: {config['strict_database']!r}"
102
+ )
103
+ if "catalog_name" in config and not _non_empty_string(config["catalog_name"]):
104
+ raise ValueError(
105
+ f"Invalid 'catalog_name'. Expected a non-empty string. Received: {config['catalog_name']!r}"
106
+ )
107
+ if "databases" in config:
108
+ databases = config["databases"]
109
+ if not isinstance(databases, list) or not all(
110
+ _non_empty_string(d) for d in databases
111
+ ):
112
+ raise ValueError(
113
+ f"Invalid 'databases'. Expected a list of database names. Received: {databases!r}"
114
+ )
115
+
116
+
117
+ def _validate_table_database(config: dict, table: dict):
118
+ name = table["table_name"]
119
+ if "catalog_name" in table and not _non_empty_string(table["catalog_name"]):
120
+ raise ValueError(f"The 'catalog_name' of table '{name}' must be a non-empty string.")
121
+ database_name = table.get("database_name")
122
+ if config.get("strict_database") and not _non_empty_string(database_name):
123
+ raise ValueError(
124
+ f"Table '{name}' must have a 'database_name' when 'strict_database' is set."
125
+ )
126
+ if database_name and "databases" in config:
127
+ if database_name.lower() not in {d.lower() for d in config["databases"]}:
128
+ raise ValueError(
129
+ f"The database '{database_name}' of table '{name}' is not in 'databases'."
130
+ )
131
+
132
+
133
+ def _validate_force_limit(config: dict):
134
+ if "force_limit" not in config:
135
+ return
136
+ limit = config["force_limit"]
137
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
138
+ raise ValueError(
139
+ f"Invalid 'force_limit'. Expected a positive integer. Received: {limit!r}"
140
+ )
141
+
142
+
143
+ def _validate_function_list(config: dict, key: str):
144
+ if key not in config:
145
+ return
146
+ names = config[key]
147
+ if not isinstance(names, list) or not all(
148
+ isinstance(n, str) and n.strip() for n in names
149
+ ):
150
+ raise ValueError(
151
+ f"Invalid '{key}'. Expected a list of function names. Received: {names!r}"
152
+ )
153
+
154
+
155
+ def validate_restrictions(config: dict):
156
+ """
157
+ Validates the restrictions in the configuration to ensure only supported operations are used.
158
+
159
+ Args:
160
+ config (dict): The configuration containing the restrictions to validate.
161
+
162
+ Raises:
163
+ UnsupportedRestrictionError: If an unsupported restriction operation is found.
164
+ ValueError: If there are no tables in the configuration.
165
+ """
166
+ supported_operations = [
167
+ "=",
168
+ ">",
169
+ "<",
170
+ ">=",
171
+ "<=",
172
+ "BETWEEN",
173
+ "IN",
174
+ ] # Allowed operations
175
+ _validate_max_length(config)
176
+ _validate_database_options(config)
177
+ _validate_force_limit(config)
178
+ _validate_function_list(config, "blocked_functions")
179
+ _validate_function_list(config, "allowed_functions")
180
+ # Ensure 'tables' exists in config and is not empty
181
+ tables = config.get("tables", [])
182
+ # Check if tables are empty
183
+ if not tables:
184
+ raise ValueError("Configuration must contain at least one table.")
185
+ if not isinstance(tables, list):
186
+ raise ValueError("'tables' must be a list.")
187
+
188
+ seen = set()
189
+ for table in tables:
190
+ if not isinstance(table, dict):
191
+ raise ValueError("Each table must be an object.")
192
+ # Ensure that 'table_name' exists in each table
193
+ if "table_name" not in table:
194
+ raise ValueError("Each table must have a 'table_name' key.")
195
+ if not isinstance(table["table_name"], str) or not table["table_name"].strip():
196
+ raise ValueError("Each 'table_name' must be a non-empty string.")
197
+ database_name = table.get("database_name")
198
+ if database_name is not None and not isinstance(database_name, str):
199
+ raise ValueError(
200
+ f"The 'database_name' of table '{table['table_name']}' must be a string."
201
+ )
202
+ _validate_table_database(config, table)
203
+ key = (
204
+ table["table_name"].lower(),
205
+ (database_name or "").lower(),
206
+ str(table.get("catalog_name") or "").lower(),
207
+ )
208
+ if key in seen:
209
+ raise ValueError(
210
+ f"Table '{table['table_name']}' is defined more than once."
211
+ )
212
+ seen.add(key)
213
+ _validate_columns(table)
214
+
215
+ restrictions = table.get("restrictions", [])
216
+ if not isinstance(restrictions, list):
217
+ raise ValueError(
218
+ f"The 'restrictions' of table '{table['table_name']}' must be a list."
219
+ )
220
+
221
+ for restriction in restrictions:
222
+ _validate_restriction_shape(restriction, table["table_name"])
223
+ operation = restriction.get("operation")
224
+ if operation == "BETWEEN":
225
+ values = restriction.get("values")
226
+ if not (
227
+ isinstance(values, list)
228
+ and len(values) == 2
229
+ and all(_is_number(v) for v in values)
230
+ and values[0] < values[1]
231
+ ):
232
+ raise ValueError(
233
+ f"Invalid 'BETWEEN' format. Expected list of two numeric values where min < max. Received: {values}"
234
+ )
235
+
236
+ elif operation == "IN":
237
+ values = restriction.get("values")
238
+ if not (
239
+ isinstance(values, list)
240
+ and len(values) > 0
241
+ and all(_is_scalar(v) and not isinstance(v, bool) for v in values)
242
+ ):
243
+ raise ValueError(
244
+ f"Invalid 'IN' format. Expected a non-empty list of numbers or strings. Received: {values}"
245
+ )
246
+
247
+ elif operation in (">", "<", ">=", "<="):
248
+ # comparison operators are evaluated numerically
249
+ value = restriction.get("value")
250
+ if not _is_number(value):
251
+ raise ValueError(
252
+ f"Invalid restriction value type for column '{restriction['column']}' in table '{table['table_name']}'. Expected a numeric value."
253
+ )
254
+
255
+ elif operation and operation.lower() not in supported_operations:
256
+ raise UnsupportedRestrictionError(
257
+ f"Invalid restriction: 'operation={operation}' is not supported."
258
+ )
259
+
260
+ validate_column_masks(config)
@@ -1,4 +1,4 @@
1
- from typing import List
1
+ from typing import List, Optional
2
2
 
3
3
  import sqlglot
4
4
  import sqlglot.expressions as expr
@@ -24,7 +24,13 @@ def verify_restrictions(
24
24
  else:
25
25
  and_exps = list(split_to_expressions(where_clause.this, expr.And))
26
26
  for c_t in context.config["tables"]:
27
- for from_t in [t for t in from_tables if table_matches(t, c_t)]:
27
+ for from_t in [
28
+ t
29
+ for t in from_tables
30
+ # a CTE with the name of a configured table is not that table (its body is verified,
31
+ # with its restrictions, on its own)
32
+ if table_matches(t, c_t) and (t.db or t.name not in context.cte_names)
33
+ ]:
28
34
  for r in c_t.get("restrictions", []):
29
35
  found = False
30
36
  for sub_exp in and_exps:
@@ -34,7 +40,10 @@ def verify_restrictions(
34
40
  if not found:
35
41
  if from_t.alias:
36
42
  t_prefix = f"{from_t.alias}."
37
- elif len([t for t in from_tables if same_name(t.name, from_t.name)]) > 1:
43
+ elif (
44
+ len([t for t in from_tables if same_name(t.name, from_t.name)])
45
+ > 1
46
+ ):
38
47
  t_prefix = f"{from_t.name}."
39
48
  else:
40
49
  t_prefix = ""
@@ -87,11 +96,15 @@ def _create_new_condition(
87
96
  operand = f"({', '.join(_format_value(v, dialect) for v in values)})"
88
97
  else:
89
98
  operator = operation if operation in _COMPARISON_OPERATORS else "="
90
- operand = (
91
- _format_value(restriction["value"], dialect)
92
- if "value" in restriction
93
- else str(restriction["values"])[1:-1]
99
+ values = (
100
+ [restriction["value"]] if "value" in restriction else restriction["values"]
94
101
  )
102
+ if operator == "=" and len(values) > 1:
103
+ # `=` with several allowed values: the column may equal any of them
104
+ operator = "IN"
105
+ operand = f"({', '.join(_format_value(v, dialect) for v in values)})"
106
+ else:
107
+ operand = _format_value(values[0], dialect)
95
108
  new_condition = sqlglot.parse_one(
96
109
  f"{table_prefix}{restriction['column']} {operator} {operand}",
97
110
  dialect=dialect,
@@ -143,54 +156,112 @@ def _verify_restriction(
143
156
  ):
144
157
  return False
145
158
 
146
- if exp.this.table and from_table.alias and not same_name(exp.this.table, from_table.alias):
159
+ if (
160
+ exp.this.table
161
+ and from_table.alias
162
+ and not same_name(exp.this.table, from_table.alias)
163
+ ):
147
164
  return False
148
- if exp.this.table and not from_table.alias and not same_name(exp.this.table, from_table.name):
165
+ if (
166
+ exp.this.table
167
+ and not from_table.alias
168
+ and not same_name(exp.this.table, from_table.name)
169
+ ):
149
170
  return False
150
171
 
151
172
  values = _get_restriction_values(restriction) # Get correct restriction values
173
+ operation = restriction.get("operation")
152
174
 
153
- # Handle IN condition correctly
175
+ # Only literal values count: a column (`region = eu`, where `eu` is a column of a derived
176
+ # table), a sub-query (`IN (SELECT 999)`) or an expression could evaluate to anything.
154
177
  if isinstance(exp, expr.In):
155
- expr_values = [str(val.this) for val in exp.expressions]
156
- return all(v in values for v in expr_values)
178
+ if exp.args.get("query") or exp.args.get("unnest") or not exp.expressions:
179
+ return False
180
+ expr_values = [_literal_value(val) for val in exp.expressions]
181
+ return all(
182
+ v is not None and _value_allowed(operation, values, v) for v in expr_values
183
+ )
157
184
 
158
- # Handle EQ (=) condition
159
- if isinstance(exp, expr.EQ) and isinstance(exp.right, expr.Condition):
160
- return str(exp.right.this) in values
185
+ if isinstance(exp, expr.EQ):
186
+ value = _literal_value(exp.right)
187
+ return value is not None and _value_allowed(operation, values, value)
161
188
 
162
189
  if isinstance(exp, expr.Between):
163
- low = _to_number(exp.args["low"].this)
164
- high = _to_number(exp.args["high"].this)
165
- restriction_values = [_to_number(v) for v in values]
166
- if len(values) == 2 and None not in [low, high, *restriction_values]:
167
- return restriction_values[0] <= low and high <= restriction_values[1]
190
+ low = _to_number(_literal_value(exp.args["low"]))
191
+ high = _to_number(_literal_value(exp.args["high"]))
192
+ if low is None or high is None:
193
+ return False
194
+ if operation == "BETWEEN":
195
+ low_limit, high_limit = (_to_number(v) for v in values)
196
+ return (
197
+ low_limit is not None
198
+ and high_limit is not None
199
+ and low_limit <= low
200
+ and high <= high_limit
201
+ )
202
+ if operation in (">", ">="):
203
+ return _value_allowed(operation, values, str(low))
204
+ if operation in ("<", "<="):
205
+ return _value_allowed(operation, values, str(high))
168
206
  return False
169
207
 
170
- if isinstance(exp, (expr.LT, expr.LTE, expr.GT, expr.GTE)) and isinstance(
171
- exp.right, expr.Condition
172
- ):
173
- if restriction.get("operation") not in _COMPARISON_OPERATORS:
208
+ if isinstance(exp, (expr.LT, expr.LTE, expr.GT, expr.GTE)):
209
+ if operation not in _COMPARISON_OPERATORS:
174
210
  return False
175
- if len(values) != 1:
211
+ upper = isinstance(exp, (expr.LT, expr.LTE)) # `col < x` / `col <= x`
212
+ if upper != (operation in ("<", "<=")):
176
213
  return False
177
- actual = _to_number(exp.right.this)
214
+ bound = _to_number(_literal_value(exp.right))
178
215
  limit = _to_number(values[0])
179
- if actual is None or limit is None:
180
- return False
181
- if isinstance(exp, expr.LT) and restriction["operation"] == "<":
182
- return actual < limit
183
- elif isinstance(exp, expr.LTE) and restriction["operation"] == "<=":
184
- return actual <= limit
185
- elif isinstance(exp, expr.GT) and restriction["operation"] == ">":
186
- return actual > limit
187
- elif isinstance(exp, expr.GTE) and restriction["operation"] == ">=":
188
- return actual >= limit
189
- else:
216
+ if bound is None or limit is None:
190
217
  return False
218
+ # the query's bound must be at least as strict as the restriction's
219
+ if operation == "<" and isinstance(exp, expr.LTE):
220
+ return bound < limit
221
+ if operation == ">" and isinstance(exp, expr.GTE):
222
+ return bound > limit
223
+ return bound <= limit if upper else bound >= limit
191
224
  return False
192
225
 
193
226
 
227
+ def _literal_value(node: Optional[expr.Expression]) -> Optional[str]:
228
+ """
229
+ The value of a literal operand, as a string (`'eu'`, `123`, `-5`, `TRUE`), or None if the
230
+ operand is anything else (a column, a sub-query, a function or other expression).
231
+ """
232
+ while isinstance(node, expr.Paren):
233
+ node = node.this
234
+ if isinstance(node, expr.Literal):
235
+ return node.this
236
+ if isinstance(node, expr.Boolean):
237
+ return str(node.this)
238
+ if (
239
+ isinstance(node, expr.Neg)
240
+ and isinstance(node.this, expr.Literal)
241
+ and not node.this.is_string
242
+ ):
243
+ return f"-{node.this.this}"
244
+ return None
245
+
246
+
247
+ def _value_allowed(operation: Optional[str], values: List[str], value: str) -> bool:
248
+ """True if a column equal to `value` satisfies the restriction."""
249
+ if operation == "BETWEEN":
250
+ number, low, high = (_to_number(v) for v in (value, *values))
251
+ return None not in (number, low, high) and low <= number <= high
252
+ if operation in _COMPARISON_OPERATORS:
253
+ number, limit = _to_number(value), _to_number(values[0])
254
+ if number is None or limit is None:
255
+ return False
256
+ return {
257
+ "<": number < limit,
258
+ "<=": number <= limit,
259
+ ">": number > limit,
260
+ ">=": number >= limit,
261
+ }[operation]
262
+ return value in values
263
+
264
+
194
265
  def _get_restriction_values(restriction: dict) -> List[str]:
195
266
  if "values" in restriction:
196
267
  values = [str(v) for v in restriction["values"]]