core-framework 0.12.9__py3-none-any.whl → 0.12.10__py3-none-any.whl

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.
core/__init__.py CHANGED
@@ -41,6 +41,15 @@ from core.dependencies import Depends, get_db, get_current_user
41
41
  from core.config import Settings, get_settings
42
42
  from core.app import CoreApp
43
43
 
44
+ # Validation
45
+ from core.validation import (
46
+ SchemaModelValidator,
47
+ SchemaModelMismatchError,
48
+ ValidationWarning,
49
+ validate_schema,
50
+ validate_all_viewsets,
51
+ )
52
+
44
53
  # Advanced Fields (UUID7, JSON, etc.)
45
54
  from core.fields import (
46
55
  uuid7,
@@ -278,7 +287,7 @@ from core.exceptions import (
278
287
  MissingDependency,
279
288
  )
280
289
 
281
- __version__ = "0.12.9"
290
+ __version__ = "0.12.10"
282
291
  __all__ = [
283
292
  # Models
284
293
  "Model",
core/app.py CHANGED
@@ -158,6 +158,13 @@ class CoreApp:
158
158
 
159
159
  async def _startup(self) -> None:
160
160
  """Executa tarefas de startup."""
161
+ import logging
162
+ logger = logging.getLogger("core.app")
163
+
164
+ # Schema/Model validation (before database init for fail-fast)
165
+ if getattr(self.settings, "strict_validation", self.settings.debug):
166
+ await self._validate_schemas()
167
+
161
168
  # Verifica se deve usar replicas
162
169
  if self.settings.has_read_replica:
163
170
  # Inicializa com read/write replicas
@@ -195,6 +202,50 @@ class CoreApp:
195
202
  if hasattr(result, "__await__"):
196
203
  await result
197
204
 
205
+ async def _validate_schemas(self) -> None:
206
+ """
207
+ Validate all ViewSet schemas against their models.
208
+
209
+ Called during startup if strict_validation is enabled.
210
+ In DEBUG mode, raises SchemaModelMismatchError on critical issues.
211
+ In production, logs errors but continues.
212
+ """
213
+ import logging
214
+ logger = logging.getLogger("core.app")
215
+
216
+ try:
217
+ from core.views import validate_pending_viewsets
218
+ from core.validation import SchemaModelMismatchError
219
+
220
+ logger.info("Running schema/model validations...")
221
+
222
+ # In debug mode, fail fast on critical issues
223
+ strict = self.settings.debug
224
+
225
+ try:
226
+ issues = validate_pending_viewsets(strict=strict)
227
+
228
+ if issues:
229
+ logger.warning(
230
+ f"Schema validation completed with {len(issues)} issues"
231
+ )
232
+ else:
233
+ logger.info("Schema validation passed")
234
+
235
+ except SchemaModelMismatchError as e:
236
+ if self.settings.debug:
237
+ logger.error(f"Schema validation failed: {e}")
238
+ raise RuntimeError(
239
+ f"Schema validation errors (set DEBUG=False to skip):\n{e}"
240
+ ) from e
241
+ else:
242
+ logger.error(f"Schema validation errors (ignored): {e}")
243
+
244
+ except ImportError:
245
+ logger.debug("Validation module not available, skipping")
246
+ except Exception as e:
247
+ logger.warning(f"Could not validate schemas: {e}")
248
+
198
249
  async def _shutdown(self) -> None:
199
250
  """Executa tarefas de shutdown."""
200
251
  # Executa callbacks customizados
core/auth/views.py CHANGED
@@ -108,14 +108,22 @@ class AuthViewSet(ViewSet):
108
108
 
109
109
  def _get_register_schema(self) -> type:
110
110
  """
111
- Bug #5 Fix: Get registration schema with extra fields support.
111
+ Get registration schema with STRICT extra fields support.
112
112
 
113
- If extra_register_fields is set, creates a dynamic schema that
114
- accepts those additional fields.
113
+ This method validates against the model to determine if fields are
114
+ required (NOT NULL) or optional (nullable).
115
+
116
+ Rules:
117
+ - If model column is NOT NULL and has no default -> REQUIRED in schema
118
+ - If model column is nullable or has default -> OPTIONAL in schema
115
119
 
116
120
  Returns:
117
121
  Pydantic schema class for registration
118
122
  """
123
+ import logging
124
+ import warnings
125
+ logger = logging.getLogger("core.auth")
126
+
119
127
  # If register_schema was explicitly overridden, use it
120
128
  if self.register_schema != BaseRegisterInput:
121
129
  return self.register_schema
@@ -124,32 +132,53 @@ class AuthViewSet(ViewSet):
124
132
  if not self.extra_register_fields:
125
133
  return BaseRegisterInput
126
134
 
127
- # Create dynamic schema with extra fields
135
+ # Return cached schema if available
128
136
  if self._dynamic_register_schema is not None:
129
137
  return self._dynamic_register_schema
130
138
 
131
- # Build extra fields - all as optional strings by default
132
- # Users can provide type hints via annotations in User model
133
- extra_fields = {}
134
139
  User = self._get_user_model()
135
- user_annotations = getattr(User, "__annotations__", {})
140
+
141
+ # Get model column info for nullable/required check
142
+ model_columns = {}
143
+ try:
144
+ from sqlalchemy import inspect
145
+ mapper = inspect(User)
146
+ model_columns = {col.name: col for col in mapper.columns}
147
+ except Exception as e:
148
+ logger.debug(f"Could not inspect User model: {e}")
149
+
150
+ extra_fields = {}
136
151
 
137
152
  for field_name in self.extra_register_fields:
138
- # Try to get type from User model
139
- field_type = user_annotations.get(field_name, str)
140
- # Extract actual type from Mapped[...] if needed
141
- field_type_str = str(field_type)
142
- if "Mapped[" in field_type_str:
143
- # It's a Mapped type, try to extract inner type
144
- if "str" in field_type_str:
145
- extra_fields[field_name] = (str | None, None)
146
- elif "int" in field_type_str:
147
- extra_fields[field_name] = (int | None, None)
148
- elif "bool" in field_type_str:
149
- extra_fields[field_name] = (bool | None, None)
153
+ col = model_columns.get(field_name)
154
+
155
+ if col is not None:
156
+ # Determine type from model
157
+ python_type = self._get_python_type_from_column(col.type)
158
+
159
+ # Check if field is required
160
+ is_nullable = col.nullable
161
+ has_default = col.default is not None or col.server_default is not None
162
+
163
+ if not is_nullable and not has_default:
164
+ # REQUIRED field - use ... (Ellipsis) as default
165
+ extra_fields[field_name] = (python_type, ...)
166
+ logger.info(
167
+ f"Field '{field_name}' is NOT NULL in model, "
168
+ f"adding as REQUIRED to register schema"
169
+ )
150
170
  else:
151
- extra_fields[field_name] = (str | None, None)
171
+ # Optional field
172
+ extra_fields[field_name] = (python_type | None, None)
152
173
  else:
174
+ # Field not in model columns, warn and make optional
175
+ warnings.warn(
176
+ f"Field '{field_name}' in extra_register_fields "
177
+ f"not found in {User.__name__} model columns. "
178
+ f"Adding as optional str.",
179
+ UserWarning,
180
+ stacklevel=2,
181
+ )
153
182
  extra_fields[field_name] = (str | None, None)
154
183
 
155
184
  # Create dynamic model
@@ -160,14 +189,61 @@ class AuthViewSet(ViewSet):
160
189
  **extra_fields,
161
190
  )
162
191
 
163
- # Allow extra fields
192
+ # Allow extra fields (ignore unknown)
164
193
  self._dynamic_register_schema.model_config = {
165
194
  **BaseRegisterInput.model_config,
166
- "extra": "ignore", # Ignore unknown fields instead of forbidding
195
+ "extra": "ignore",
167
196
  }
168
197
 
169
198
  return self._dynamic_register_schema
170
199
 
200
+ def _get_python_type_from_column(self, sa_type) -> type:
201
+ """
202
+ Convert SQLAlchemy column type to Python type.
203
+
204
+ Args:
205
+ sa_type: SQLAlchemy type instance
206
+
207
+ Returns:
208
+ Corresponding Python type
209
+ """
210
+ from sqlalchemy import String, Integer, Boolean, Float, Text, DateTime, Date
211
+
212
+ type_map = {
213
+ String: str,
214
+ Text: str,
215
+ Integer: int,
216
+ Boolean: bool,
217
+ Float: float,
218
+ }
219
+
220
+ for sa_cls, py_type in type_map.items():
221
+ if isinstance(sa_type, sa_cls):
222
+ return py_type
223
+
224
+ # Check type name for dialect-specific types
225
+ type_name = type(sa_type).__name__
226
+ if "String" in type_name or "Text" in type_name or "VARCHAR" in type_name:
227
+ return str
228
+ if "Integer" in type_name or "INT" in type_name:
229
+ return int
230
+ if "Boolean" in type_name or "BOOL" in type_name:
231
+ return bool
232
+ if "Float" in type_name or "Numeric" in type_name or "Decimal" in type_name:
233
+ return float
234
+ if "DateTime" in type_name or "Timestamp" in type_name:
235
+ from datetime import datetime
236
+ return datetime
237
+ if "Date" in type_name:
238
+ from datetime import date
239
+ return date
240
+ if "UUID" in type_name:
241
+ from uuid import UUID
242
+ return UUID
243
+
244
+ # Default to str
245
+ return str
246
+
171
247
  def _create_tokens(self, user) -> dict:
172
248
  """
173
249
  Bug #6 Fix: Create access and refresh tokens using current API.
core/config.py CHANGED
@@ -76,6 +76,26 @@ class Settings(BaseSettings):
76
76
  description="Chave secreta para criptografia e tokens",
77
77
  )
78
78
 
79
+ # =========================================================================
80
+ # Validation
81
+ # =========================================================================
82
+
83
+ strict_validation: bool = PydanticField(
84
+ default=True,
85
+ description=(
86
+ "Habilita validação rigorosa de schemas contra models. "
87
+ "Em modo strict, erros críticos (campo NOT NULL opcional no schema) "
88
+ "causam falha no startup em DEBUG mode."
89
+ ),
90
+ )
91
+ validation_fail_fast: bool | None = PydanticField(
92
+ default=None,
93
+ description=(
94
+ "Se True, falha no primeiro erro de validação. "
95
+ "Se None, usa valor de DEBUG."
96
+ ),
97
+ )
98
+
79
99
  # =========================================================================
80
100
  # Database
81
101
  # =========================================================================
core/validation.py ADDED
@@ -0,0 +1,768 @@
1
+ """
2
+ Sistema de Validação Rigorosa para Core Framework.
3
+
4
+ Este módulo implementa validação automática entre Pydantic schemas e
5
+ SQLAlchemy models, garantindo consistência e prevenindo erros em runtime.
6
+
7
+ Regras de Validação:
8
+ R1: Campo NOT NULL no model = REQUIRED no schema (Erro em strict)
9
+ R2: max_length do model >= max_length do schema (Warning)
10
+ R3: Tipo do schema deve ser compatível com model (Warning)
11
+ R4: Campo extra não existe no model (Warning)
12
+ R5: Validação executada no startup (Fail-fast em DEBUG)
13
+
14
+ Usage:
15
+ from core.validation import SchemaModelValidator
16
+
17
+ # Validar manualmente
18
+ issues = SchemaModelValidator.validate(
19
+ UserInput,
20
+ User,
21
+ strict=True,
22
+ context="UserViewSet"
23
+ )
24
+
25
+ # Ou deixar o framework validar automaticamente no startup
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ import warnings
32
+ from typing import TYPE_CHECKING, Any, get_args, get_origin
33
+
34
+ if TYPE_CHECKING:
35
+ from pydantic import BaseModel
36
+ from pydantic.fields import FieldInfo
37
+
38
+ logger = logging.getLogger("core.validation")
39
+
40
+
41
+ # =============================================================================
42
+ # Exceptions
43
+ # =============================================================================
44
+
45
+ class SchemaModelMismatchError(Exception):
46
+ """
47
+ Raised when schema and model have incompatible field definitions.
48
+
49
+ This error indicates a critical mismatch that will likely cause
50
+ runtime errors (e.g., database integrity violations).
51
+ """
52
+ pass
53
+
54
+
55
+ class ValidationWarning(UserWarning):
56
+ """Warning for non-critical schema/model mismatches."""
57
+ pass
58
+
59
+
60
+ # =============================================================================
61
+ # Type Mapping
62
+ # =============================================================================
63
+
64
+ # SQLAlchemy type to Python type mapping
65
+ _SQLALCHEMY_TYPE_MAP: dict[str, type] = {
66
+ "String": str,
67
+ "Text": str,
68
+ "VARCHAR": str,
69
+ "CHAR": str,
70
+ "Integer": int,
71
+ "BigInteger": int,
72
+ "SmallInteger": int,
73
+ "BIGINT": int,
74
+ "SMALLINT": int,
75
+ "INT": int,
76
+ "INTEGER": int,
77
+ "Boolean": bool,
78
+ "BOOLEAN": bool,
79
+ "Float": float,
80
+ "FLOAT": float,
81
+ "Numeric": float,
82
+ "DECIMAL": float,
83
+ "DateTime": "datetime",
84
+ "DATETIME": "datetime",
85
+ "Date": "date",
86
+ "DATE": "date",
87
+ "Time": "time",
88
+ "TIME": "time",
89
+ "UUID": "uuid",
90
+ "JSON": dict,
91
+ "JSONB": dict,
92
+ "ARRAY": list,
93
+ "LargeBinary": bytes,
94
+ "BLOB": bytes,
95
+ }
96
+
97
+
98
+ # =============================================================================
99
+ # Schema Model Validator
100
+ # =============================================================================
101
+
102
+ class SchemaModelValidator:
103
+ """
104
+ Validates that Pydantic schemas match SQLAlchemy models.
105
+
106
+ This validator ensures consistency between your API contracts (schemas)
107
+ and your database structure (models), preventing common errors like:
108
+
109
+ - Allowing None for NOT NULL columns
110
+ - Accepting strings longer than the column allows
111
+ - Type mismatches between schema and model
112
+
113
+ Rules:
114
+ 1. If model field is NOT NULL, schema field MUST be required
115
+ 2. If model field has max_length, schema should validate it
116
+ 3. If model field has constraints, schema should mirror them
117
+ 4. Fields in schema but not in model generate warnings
118
+
119
+ Example:
120
+ >>> from core.validation import SchemaModelValidator
121
+ >>>
122
+ >>> class User(Model):
123
+ ... name = Column(String(100), nullable=False)
124
+ ... bio = Column(Text, nullable=True)
125
+ >>>
126
+ >>> class UserInput(BaseModel):
127
+ ... name: str | None = None # BUG: Should be required!
128
+ ... bio: str | None = None # OK: nullable in model
129
+ >>>
130
+ >>> SchemaModelValidator.validate(UserInput, User, strict=True)
131
+ # Raises SchemaModelMismatchError!
132
+ """
133
+
134
+ # Cache for validated pairs to avoid re-validation
135
+ _validated_pairs: set[tuple[type, type]] = set()
136
+
137
+ @classmethod
138
+ def validate(
139
+ cls,
140
+ schema: type["BaseModel"],
141
+ model: type,
142
+ *,
143
+ strict: bool = True,
144
+ context: str = "",
145
+ check_output: bool = False,
146
+ ) -> list[str]:
147
+ """
148
+ Validate schema against model.
149
+
150
+ Args:
151
+ schema: Pydantic schema class (BaseModel subclass)
152
+ model: SQLAlchemy model class
153
+ strict: If True, raise error on critical mismatch.
154
+ If False, just warn and continue.
155
+ context: Context for error messages (e.g., "UserViewSet.register")
156
+ check_output: If True, this is an output schema (less strict)
157
+
158
+ Returns:
159
+ List of issues found (warnings and errors)
160
+
161
+ Raises:
162
+ SchemaModelMismatchError: If strict=True and critical issues found
163
+
164
+ Example:
165
+ >>> issues = SchemaModelValidator.validate(
166
+ ... UserCreateInput,
167
+ ... User,
168
+ ... strict=True,
169
+ ... context="UserViewSet.create"
170
+ ... )
171
+ """
172
+ # Check cache
173
+ cache_key = (schema, model)
174
+ if cache_key in cls._validated_pairs:
175
+ return []
176
+
177
+ issues: list[str] = []
178
+ critical_issues: list[str] = []
179
+
180
+ # Get schema fields
181
+ schema_fields = cls._get_schema_fields(schema)
182
+ if not schema_fields:
183
+ return []
184
+
185
+ # Get model columns
186
+ model_columns = cls._get_model_columns(model)
187
+ if not model_columns:
188
+ logger.debug(f"Could not inspect model {model.__name__}, skipping validation")
189
+ return []
190
+
191
+ ctx = f"[{context}] " if context else ""
192
+
193
+ for field_name, field_info in schema_fields.items():
194
+ # Skip fields not in model (could be computed fields)
195
+ if field_name not in model_columns:
196
+ continue
197
+
198
+ col_info = model_columns[field_name]
199
+
200
+ # Rule 1: NOT NULL check (critical for input schemas)
201
+ if not check_output:
202
+ r1_issue = cls._check_nullable_mismatch(
203
+ field_name, field_info, col_info, ctx
204
+ )
205
+ if r1_issue:
206
+ critical_issues.append(r1_issue)
207
+
208
+ # Rule 2: max_length check
209
+ r2_issue = cls._check_max_length(
210
+ field_name, field_info, col_info, ctx
211
+ )
212
+ if r2_issue:
213
+ issues.append(r2_issue)
214
+
215
+ # Rule 3: Type compatibility check
216
+ r3_issue = cls._check_type_compatibility(
217
+ field_name, field_info, col_info, ctx
218
+ )
219
+ if r3_issue:
220
+ issues.append(r3_issue)
221
+
222
+ # Rule 4: Check for unknown fields (fields in schema not in model)
223
+ for field_name in schema_fields:
224
+ if field_name not in model_columns and field_name not in ("id", "created_at", "updated_at"):
225
+ issues.append(
226
+ f"{ctx}Field '{field_name}' in schema but not in model "
227
+ f"{model.__name__}. This may be intentional for computed fields."
228
+ )
229
+
230
+ # Process issues
231
+ all_issues = critical_issues + issues
232
+
233
+ for issue in issues:
234
+ logger.warning(issue)
235
+ if not strict:
236
+ warnings.warn(issue, ValidationWarning, stacklevel=3)
237
+
238
+ for issue in critical_issues:
239
+ logger.error(issue)
240
+
241
+ # Raise if strict mode and critical issues found
242
+ if strict and critical_issues:
243
+ cls._validated_pairs.discard(cache_key)
244
+ raise SchemaModelMismatchError(
245
+ f"Schema/Model validation failed:\n" + "\n".join(critical_issues)
246
+ )
247
+
248
+ # Cache successful validation
249
+ cls._validated_pairs.add(cache_key)
250
+
251
+ return all_issues
252
+
253
+ @classmethod
254
+ def validate_viewset(
255
+ cls,
256
+ viewset_class: type,
257
+ *,
258
+ strict: bool = True,
259
+ ) -> list[str]:
260
+ """
261
+ Validate all schemas in a ViewSet against its model.
262
+
263
+ Args:
264
+ viewset_class: ViewSet class to validate
265
+ strict: If True, raise on critical issues
266
+
267
+ Returns:
268
+ List of all issues found
269
+ """
270
+ issues: list[str] = []
271
+ context = viewset_class.__name__
272
+
273
+ model = getattr(viewset_class, "model", None)
274
+ if not model:
275
+ return []
276
+
277
+ # Validate input schema (strict for required fields)
278
+ input_schema = getattr(viewset_class, "input_schema", None)
279
+ if input_schema:
280
+ issues.extend(cls.validate(
281
+ input_schema,
282
+ model,
283
+ strict=strict,
284
+ context=f"{context}.input_schema",
285
+ check_output=False,
286
+ ))
287
+
288
+ # Validate output schema (less strict)
289
+ output_schema = getattr(viewset_class, "output_schema", None)
290
+ if output_schema:
291
+ issues.extend(cls.validate(
292
+ output_schema,
293
+ model,
294
+ strict=False, # Output can have fewer/different fields
295
+ context=f"{context}.output_schema",
296
+ check_output=True,
297
+ ))
298
+
299
+ # Validate create schema
300
+ create_schema = getattr(viewset_class, "create_schema", None)
301
+ if create_schema and create_schema != input_schema:
302
+ issues.extend(cls.validate(
303
+ create_schema,
304
+ model,
305
+ strict=strict,
306
+ context=f"{context}.create_schema",
307
+ check_output=False,
308
+ ))
309
+
310
+ # Validate update schema (optional fields OK)
311
+ update_schema = getattr(viewset_class, "update_schema", None)
312
+ if update_schema and update_schema != input_schema:
313
+ issues.extend(cls.validate(
314
+ update_schema,
315
+ model,
316
+ strict=False, # Update schemas can have optional fields
317
+ context=f"{context}.update_schema",
318
+ check_output=False,
319
+ ))
320
+
321
+ return issues
322
+
323
+ @classmethod
324
+ def clear_cache(cls) -> None:
325
+ """Clear the validation cache."""
326
+ cls._validated_pairs.clear()
327
+
328
+ # =========================================================================
329
+ # Private Methods
330
+ # =========================================================================
331
+
332
+ @classmethod
333
+ def _get_schema_fields(cls, schema: type["BaseModel"]) -> dict[str, "FieldInfo"]:
334
+ """Extract field info from Pydantic schema."""
335
+ try:
336
+ return schema.model_fields
337
+ except AttributeError:
338
+ # Pydantic v1 fallback
339
+ try:
340
+ return schema.__fields__
341
+ except AttributeError:
342
+ return {}
343
+
344
+ @classmethod
345
+ def _get_model_columns(cls, model: type) -> dict[str, "ColumnInfo"]:
346
+ """
347
+ Extract column info from SQLAlchemy model.
348
+
349
+ Returns dict with column name -> ColumnInfo containing:
350
+ - nullable: bool
351
+ - max_length: int | None
352
+ - python_type: type
353
+ - sa_type: SQLAlchemy type instance
354
+ """
355
+ try:
356
+ from sqlalchemy import inspect
357
+ from sqlalchemy.orm import Mapper
358
+
359
+ mapper: Mapper = inspect(model)
360
+ columns = {}
361
+
362
+ for col in mapper.columns:
363
+ columns[col.name] = ColumnInfo(
364
+ name=col.name,
365
+ nullable=col.nullable,
366
+ max_length=getattr(col.type, "length", None),
367
+ python_type=cls._get_python_type(col.type),
368
+ sa_type=col.type,
369
+ has_default=col.default is not None or col.server_default is not None,
370
+ is_primary_key=col.primary_key,
371
+ is_autoincrement=getattr(col, "autoincrement", False),
372
+ )
373
+
374
+ return columns
375
+ except Exception as e:
376
+ logger.debug(f"Could not inspect model: {e}")
377
+ return {}
378
+
379
+ @classmethod
380
+ def _get_python_type(cls, sa_type: Any) -> type:
381
+ """Convert SQLAlchemy type to Python type."""
382
+ type_name = type(sa_type).__name__
383
+
384
+ # Check direct mapping
385
+ if type_name in _SQLALCHEMY_TYPE_MAP:
386
+ mapped = _SQLALCHEMY_TYPE_MAP[type_name]
387
+ if isinstance(mapped, str):
388
+ # Handle special types
389
+ if mapped == "datetime":
390
+ from datetime import datetime
391
+ return datetime
392
+ elif mapped == "date":
393
+ from datetime import date
394
+ return date
395
+ elif mapped == "time":
396
+ from datetime import time
397
+ return time
398
+ elif mapped == "uuid":
399
+ from uuid import UUID
400
+ return UUID
401
+ return mapped
402
+
403
+ # Try to get impl type
404
+ try:
405
+ impl = getattr(sa_type, "impl", None)
406
+ if impl:
407
+ return cls._get_python_type(impl)
408
+ except Exception:
409
+ pass
410
+
411
+ # Default to Any
412
+ return Any
413
+
414
+ @classmethod
415
+ def _check_nullable_mismatch(
416
+ cls,
417
+ field_name: str,
418
+ field_info: "FieldInfo",
419
+ col_info: "ColumnInfo",
420
+ ctx: str,
421
+ ) -> str | None:
422
+ """
423
+ Rule 1: Check if NOT NULL column has optional schema field.
424
+
425
+ This is a CRITICAL issue - will cause IntegrityError at runtime.
426
+ """
427
+ # Skip primary keys and auto-generated fields
428
+ if col_info.is_primary_key or col_info.is_autoincrement:
429
+ return None
430
+
431
+ # Skip fields with defaults
432
+ if col_info.has_default:
433
+ return None
434
+
435
+ # Check if model requires the field (NOT NULL, no default)
436
+ model_requires = not col_info.nullable and not col_info.has_default
437
+
438
+ # Check if schema makes field optional
439
+ schema_optional = not cls._is_field_required(field_info)
440
+
441
+ if model_requires and schema_optional:
442
+ return (
443
+ f"{ctx}CRITICAL: Field '{field_name}' is NOT NULL in model "
444
+ f"but OPTIONAL in schema. This WILL cause IntegrityError! "
445
+ f"Make the field required in the schema."
446
+ )
447
+
448
+ return None
449
+
450
+ @classmethod
451
+ def _check_max_length(
452
+ cls,
453
+ field_name: str,
454
+ field_info: "FieldInfo",
455
+ col_info: "ColumnInfo",
456
+ ctx: str,
457
+ ) -> str | None:
458
+ """
459
+ Rule 2: Check if schema allows values longer than model accepts.
460
+ """
461
+ if not col_info.max_length:
462
+ return None
463
+
464
+ schema_max = cls._get_schema_max_length(field_info)
465
+
466
+ if schema_max is None:
467
+ return (
468
+ f"{ctx}Field '{field_name}' has max_length={col_info.max_length} "
469
+ f"in model but no length validation in schema. "
470
+ f"Add max_length constraint to prevent truncation."
471
+ )
472
+
473
+ if schema_max > col_info.max_length:
474
+ return (
475
+ f"{ctx}Field '{field_name}' allows {schema_max} chars in schema "
476
+ f"but model only accepts {col_info.max_length}. "
477
+ f"Values may be truncated or cause errors."
478
+ )
479
+
480
+ return None
481
+
482
+ @classmethod
483
+ def _check_type_compatibility(
484
+ cls,
485
+ field_name: str,
486
+ field_info: "FieldInfo",
487
+ col_info: "ColumnInfo",
488
+ ctx: str,
489
+ ) -> str | None:
490
+ """
491
+ Rule 3: Check if schema type is compatible with model type.
492
+ """
493
+ schema_type = cls._get_schema_type(field_info)
494
+ model_type = col_info.python_type
495
+
496
+ if schema_type is None or model_type is Any:
497
+ return None
498
+
499
+ # Handle Optional types
500
+ if get_origin(schema_type) is type(None):
501
+ return None
502
+
503
+ # Unwrap Optional/Union
504
+ schema_type = cls._unwrap_optional(schema_type)
505
+
506
+ if schema_type is Any:
507
+ return None
508
+
509
+ # Check compatibility
510
+ if not cls._types_compatible(schema_type, model_type):
511
+ return (
512
+ f"{ctx}Field '{field_name}' has type {model_type.__name__} in model "
513
+ f"but {schema_type} in schema. Types may be incompatible."
514
+ )
515
+
516
+ return None
517
+
518
+ @classmethod
519
+ def _is_field_required(cls, field_info: "FieldInfo") -> bool:
520
+ """Check if Pydantic field is required."""
521
+ try:
522
+ # Pydantic v2
523
+ return field_info.is_required()
524
+ except (AttributeError, TypeError):
525
+ pass
526
+
527
+ try:
528
+ # Check default
529
+ if field_info.default is not None:
530
+ return False
531
+ if field_info.default_factory is not None:
532
+ return False
533
+ # Check if explicitly required
534
+ from pydantic_core import PydanticUndefined
535
+ return field_info.default is PydanticUndefined
536
+ except Exception:
537
+ pass
538
+
539
+ # Fallback: assume required if no default
540
+ return getattr(field_info, "default", ...) is ...
541
+
542
+ @classmethod
543
+ def _get_schema_max_length(cls, field_info: "FieldInfo") -> int | None:
544
+ """Extract max_length from Pydantic field metadata."""
545
+ # Check metadata
546
+ metadata = getattr(field_info, "metadata", []) or []
547
+ for meta in metadata:
548
+ if hasattr(meta, "max_length"):
549
+ return meta.max_length
550
+
551
+ # Check json_schema_extra
552
+ json_extra = getattr(field_info, "json_schema_extra", None)
553
+ if json_extra and isinstance(json_extra, dict):
554
+ return json_extra.get("maxLength")
555
+
556
+ return None
557
+
558
+ @classmethod
559
+ def _get_schema_type(cls, field_info: "FieldInfo") -> type | None:
560
+ """Extract type from Pydantic field."""
561
+ try:
562
+ return field_info.annotation
563
+ except AttributeError:
564
+ return getattr(field_info, "outer_type_", None)
565
+
566
+ @classmethod
567
+ def _unwrap_optional(cls, t: type) -> type:
568
+ """Unwrap Optional[X] or X | None to X."""
569
+ from types import UnionType
570
+
571
+ origin = get_origin(t)
572
+
573
+ # Handle Union types (including X | None)
574
+ if origin is UnionType or (hasattr(t, "__class__") and t.__class__ is UnionType):
575
+ args = get_args(t)
576
+ non_none = [a for a in args if a is not type(None)]
577
+ if len(non_none) == 1:
578
+ return non_none[0]
579
+ return t
580
+
581
+ # Handle typing.Union
582
+ try:
583
+ from typing import Union
584
+ if origin is Union:
585
+ args = get_args(t)
586
+ non_none = [a for a in args if a is not type(None)]
587
+ if len(non_none) == 1:
588
+ return non_none[0]
589
+ except Exception:
590
+ pass
591
+
592
+ return t
593
+
594
+ @classmethod
595
+ def _types_compatible(cls, schema_type: type, model_type: type) -> bool:
596
+ """Check if schema type is compatible with model type."""
597
+ # Same type
598
+ if schema_type is model_type:
599
+ return True
600
+
601
+ # Both are string-like
602
+ if schema_type is str and model_type is str:
603
+ return True
604
+
605
+ # Both are numeric
606
+ numeric = (int, float)
607
+ if schema_type in numeric and model_type in numeric:
608
+ return True
609
+
610
+ # Check subclass
611
+ try:
612
+ if isinstance(schema_type, type) and isinstance(model_type, type):
613
+ return issubclass(schema_type, model_type) or issubclass(model_type, schema_type)
614
+ except TypeError:
615
+ pass
616
+
617
+ return True # Be permissive for unknown types
618
+
619
+
620
+ # =============================================================================
621
+ # Column Info Data Class
622
+ # =============================================================================
623
+
624
+ class ColumnInfo:
625
+ """Information about a SQLAlchemy column."""
626
+
627
+ __slots__ = (
628
+ "name",
629
+ "nullable",
630
+ "max_length",
631
+ "python_type",
632
+ "sa_type",
633
+ "has_default",
634
+ "is_primary_key",
635
+ "is_autoincrement",
636
+ )
637
+
638
+ def __init__(
639
+ self,
640
+ name: str,
641
+ nullable: bool,
642
+ max_length: int | None,
643
+ python_type: type,
644
+ sa_type: Any,
645
+ has_default: bool = False,
646
+ is_primary_key: bool = False,
647
+ is_autoincrement: bool = False,
648
+ ):
649
+ self.name = name
650
+ self.nullable = nullable
651
+ self.max_length = max_length
652
+ self.python_type = python_type
653
+ self.sa_type = sa_type
654
+ self.has_default = has_default
655
+ self.is_primary_key = is_primary_key
656
+ self.is_autoincrement = is_autoincrement
657
+
658
+
659
+ # =============================================================================
660
+ # Validation Decorator
661
+ # =============================================================================
662
+
663
+ def validate_schema(
664
+ schema: type["BaseModel"],
665
+ model: type,
666
+ *,
667
+ strict: bool = True,
668
+ context: str = "",
669
+ ):
670
+ """
671
+ Decorator to validate schema against model.
672
+
673
+ Usage:
674
+ @validate_schema(UserInput, User, context="create_user")
675
+ async def create_user(data: UserInput) -> User:
676
+ ...
677
+ """
678
+ def decorator(func):
679
+ # Validate on decoration (import time)
680
+ SchemaModelValidator.validate(
681
+ schema,
682
+ model,
683
+ strict=strict,
684
+ context=context or func.__name__,
685
+ )
686
+ return func
687
+ return decorator
688
+
689
+
690
+ # =============================================================================
691
+ # Startup Validation
692
+ # =============================================================================
693
+
694
+ def validate_all_viewsets(
695
+ viewsets: list[type],
696
+ *,
697
+ strict: bool | None = None,
698
+ fail_fast: bool | None = None,
699
+ ) -> list[str]:
700
+ """
701
+ Validate all registered viewsets.
702
+
703
+ Called automatically during application startup.
704
+
705
+ Args:
706
+ viewsets: List of ViewSet classes to validate
707
+ strict: Override strict mode (default: based on DEBUG setting)
708
+ fail_fast: If True, raise on first error (default: DEBUG mode)
709
+
710
+ Returns:
711
+ List of all issues found
712
+
713
+ Raises:
714
+ SchemaModelMismatchError: If fail_fast and critical issues found
715
+ """
716
+ from core.config import get_settings
717
+
718
+ settings = get_settings()
719
+
720
+ if strict is None:
721
+ strict = getattr(settings, "strict_validation", getattr(settings, "debug", True))
722
+
723
+ if fail_fast is None:
724
+ fail_fast = getattr(settings, "debug", True)
725
+
726
+ logger.info(f"Validating {len(viewsets)} viewsets (strict={strict}, fail_fast={fail_fast})")
727
+
728
+ all_issues: list[str] = []
729
+ critical_errors: list[str] = []
730
+
731
+ for viewset in viewsets:
732
+ try:
733
+ issues = SchemaModelValidator.validate_viewset(
734
+ viewset,
735
+ strict=strict,
736
+ )
737
+ all_issues.extend(issues)
738
+ except SchemaModelMismatchError as e:
739
+ critical_errors.append(str(e))
740
+ if fail_fast:
741
+ raise
742
+
743
+ if critical_errors and not fail_fast:
744
+ logger.error(
745
+ f"Schema validation found {len(critical_errors)} critical errors:\n" +
746
+ "\n".join(critical_errors)
747
+ )
748
+
749
+ if all_issues:
750
+ logger.warning(f"Schema validation found {len(all_issues)} total issues")
751
+ else:
752
+ logger.info("Schema validation passed")
753
+
754
+ return all_issues
755
+
756
+
757
+ # =============================================================================
758
+ # Exports
759
+ # =============================================================================
760
+
761
+ __all__ = [
762
+ "SchemaModelValidator",
763
+ "SchemaModelMismatchError",
764
+ "ValidationWarning",
765
+ "ColumnInfo",
766
+ "validate_schema",
767
+ "validate_all_viewsets",
768
+ ]
core/views.py CHANGED
@@ -44,6 +44,58 @@ ModelT = TypeVar("ModelT")
44
44
  InputT = TypeVar("InputT", bound=InputSchema)
45
45
  OutputT = TypeVar("OutputT", bound=OutputSchema)
46
46
 
47
+ # Registry for viewsets pending validation
48
+ _pending_viewsets: set[type] = set()
49
+
50
+
51
+ def validate_pending_viewsets(*, strict: bool | None = None) -> list[str]:
52
+ """
53
+ Validate all pending viewsets.
54
+
55
+ Called automatically during application startup.
56
+
57
+ Args:
58
+ strict: Override strict mode. If None, uses each viewset's setting.
59
+
60
+ Returns:
61
+ List of all validation issues found
62
+ """
63
+ import logging
64
+ logger = logging.getLogger("core.views")
65
+
66
+ if not _pending_viewsets:
67
+ return []
68
+
69
+ logger.debug(f"Validating {len(_pending_viewsets)} viewsets...")
70
+
71
+ all_issues: list[str] = []
72
+
73
+ for viewset_cls in list(_pending_viewsets):
74
+ try:
75
+ vs_strict = strict if strict is not None else viewset_cls.strict_validation
76
+
77
+ # Temporarily set strict mode
78
+ original_strict = viewset_cls.strict_validation
79
+ viewset_cls.strict_validation = vs_strict
80
+
81
+ issues = viewset_cls._validate_schemas()
82
+ all_issues.extend(issues)
83
+
84
+ # Restore
85
+ viewset_cls.strict_validation = original_strict
86
+
87
+ except Exception as e:
88
+ all_issues.append(f"{viewset_cls.__name__}: {e}")
89
+
90
+ _pending_viewsets.clear()
91
+
92
+ if all_issues:
93
+ logger.warning(f"ViewSet validation found {len(all_issues)} issues")
94
+ else:
95
+ logger.debug("ViewSet validation passed")
96
+
97
+ return all_issues
98
+
47
99
 
48
100
  # =============================================================================
49
101
  # Decorator para actions customizadas
@@ -242,6 +294,62 @@ class ViewSet(Generic[ModelT, InputT, OutputT]):
242
294
  # Formato: {"field_name": [validator1, validator2]}
243
295
  field_validators: ClassVar[dict[str, list[AsyncValidator]]] = {}
244
296
 
297
+ # Schema/Model Validation
298
+ # Se True, valida schemas contra model no startup (falha em DEBUG)
299
+ strict_validation: ClassVar[bool] = True
300
+ # Cache de validação
301
+ _schema_validated: ClassVar[bool] = False
302
+
303
+ def __init_subclass__(cls, **kwargs: Any) -> None:
304
+ """
305
+ Called when a ViewSet subclass is created.
306
+
307
+ Registers the viewset for startup validation.
308
+ """
309
+ super().__init_subclass__(**kwargs)
310
+ cls._schema_validated = False
311
+ # Register for lazy validation
312
+ _pending_viewsets.add(cls)
313
+
314
+ @classmethod
315
+ def _validate_schemas(cls) -> list[str]:
316
+ """
317
+ Validate schemas against model.
318
+
319
+ Called automatically on application startup or first request.
320
+
321
+ Returns:
322
+ List of validation issues found
323
+
324
+ Raises:
325
+ SchemaModelMismatchError: If strict_validation=True and critical issues
326
+ """
327
+ if cls._schema_validated:
328
+ return []
329
+
330
+ model = getattr(cls, "model", None)
331
+ if not model:
332
+ cls._schema_validated = True
333
+ return []
334
+
335
+ try:
336
+ from core.validation import SchemaModelValidator
337
+
338
+ issues = SchemaModelValidator.validate_viewset(
339
+ cls,
340
+ strict=cls.strict_validation,
341
+ )
342
+
343
+ cls._schema_validated = True
344
+ return issues
345
+ except Exception as e:
346
+ import logging
347
+ logging.getLogger("core.views").warning(
348
+ f"Could not validate {cls.__name__}: {e}"
349
+ )
350
+ cls._schema_validated = True
351
+ return []
352
+
245
353
  def __init__(self) -> None:
246
354
  self.action: str | None = None
247
355
  self.request: Request | None = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: core-framework
3
- Version: 0.12.9
3
+ Version: 0.12.10
4
4
  Summary: Core Framework - Django-inspired, FastAPI-powered. Alta performance, baixo acoplamento, produtividade extrema.
5
5
  Project-URL: Homepage, https://github.com/SorPuti/core-framework
6
6
  Project-URL: Documentation, https://github.com/SorPuti/core-framework#readme
@@ -1,7 +1,7 @@
1
- core/__init__.py,sha256=-TnrJlKDPs8jlRHE0RQMWJ1Tw4LZpC9zc9IYMG4X_38,12058
2
- core/app.py,sha256=sCA3mJI696i7MIjrPxfOr5zEYt0njarQfHHy3EAajk4,21071
1
+ core/__init__.py,sha256=JCQKxeEANecnDEZpCIVsioKt0UhtuHOZ4xkXYKYr15o,12232
2
+ core/app.py,sha256=SsMC5Vlj6PNgACXlfeccOm6CQEKfgh3q3X2p9ubRDlQ,23092
3
3
  core/choices.py,sha256=rhcL3p2dB7RK99zIilpmoTFVcibQEIaRpz0CY0kImCE,10502
4
- core/config.py,sha256=2-MVF9nLoYmxpYYH_Gzn4-Sa3MU87YZskRPtlNyhg6Q,14049
4
+ core/config.py,sha256=dq4O7QBdrdwj-fZRe2yhX1fKyi_Uetb6sx9-RovJ-9c,14771
5
5
  core/database.py,sha256=XqB5tZnb9UYDbVGIh96YbmbGJZMqln6-diPBHCr3VWk,11564
6
6
  core/datetime.py,sha256=bzqlAj3foA-lzbhXjlEiDNR2D-nwXu9mpxpdcUb-Pmw,32730
7
7
  core/dependencies.py,sha256=p207A8qwj-QVAb7nNSe3HxkefClwSQNQQylSFFa-meU,11627
@@ -15,8 +15,9 @@ core/relations.py,sha256=UbdRgj0XQGI4lv2FQV1ImSAwu4Pn8yxTkSsdzR3m8cM,21372
15
15
  core/routing.py,sha256=vIiJN8bQ2836WW2zUKTJVBTC8RpjtDYgEGdz7mldnGc,15422
16
16
  core/serializers.py,sha256=gR5Y7wTACm1pECkUEpAKBUbPmONGLMDDwej4fyIiOdo,9438
17
17
  core/tenancy.py,sha256=R4tNrLcAgRRDSqOvJS2IRXcD2J-zoCE4ng01ip9xWKI,9169
18
+ core/validation.py,sha256=F6sq6g2hzhgQ10nVMc9cdXqK4Og00nCjgdkAi5TlpJw,24533
18
19
  core/validators.py,sha256=LCDyvqwIKnMaUEdaVx5kWveZt3XsydknZ_bxBL4ic5U,27895
19
- core/views.py,sha256=Vm2FREET0IJ2JZbClNJ0vvZ6RN5aQKC1sDXsrOb4-SY,43319
20
+ core/views.py,sha256=jP2HypxplVP5nHJfmhQ2d4pegnVYhl8KXVydc25l7V4,46541
20
21
  core/auth/__init__.py,sha256=_yr4rMMvDt_uKujzkKfqlQZ6x9UiQ6jmRppw14hTQNc,4645
21
22
  core/auth/backends.py,sha256=PkLk2RQN2rQdtYSiN0mn7cqSp95hnLjO9xTFZqSsPF8,10486
22
23
  core/auth/base.py,sha256=Q7vXgwTmgdmyW7G8eJmDket2bKB_8YFnraZ_kK9_gTs,21425
@@ -28,7 +29,7 @@ core/auth/models.py,sha256=aEE7deQKPS1aH0Btzzh3Z1Bwuqy8zvLZwu4JFEmiUNk,34058
28
29
  core/auth/permissions.py,sha256=v3ykAgNpq5wJ0NkuC_FuveMctOkDfM9Xp11XEnUAuBg,12461
29
30
  core/auth/schemas.py,sha256=L0W96dOD348rJDGeu1K5Rz3aJj-GdwMr2vbwwsYfo2g,3469
30
31
  core/auth/tokens.py,sha256=jOF40D5O8WRG8klRwMBuSG-jOhdsp1irXn2aZ2puNSg,9149
31
- core/auth/views.py,sha256=3gMaq8pzWoWr29ExJk21JgGay2fE3Fq3Tz99Wb_ftvE,14203
32
+ core/auth/views.py,sha256=7_ZFcXD6guIDH3asjmTq59dWon4tpCTwDGw3r98eY4c,16705
32
33
  core/cli/__init__.py,sha256=EOYSATzRugHD2oJ1SPfTIMUygUoNJnO_dRt2yJrkQcU,542
33
34
  core/cli/main.py,sha256=0WAO1deegd2Yja19ggDbX4j6p5S_p2KrmyAmale9HZs,124490
34
35
  core/deployment/__init__.py,sha256=RNcBRO9oB3WRnhtTTwM6wzVEcUKpKF4XfRkGSbbykIc,794
@@ -86,7 +87,7 @@ example/auth.py,sha256=zBpLutb8lVKnGfQqQ2wnyygsSutHYZzeJBuhnFhxBaQ,4971
86
87
  example/models.py,sha256=xKdx0kJ9n0tZ7sCce3KhV3BTvKvsh6m7G69eFm3ukf0,4549
87
88
  example/schemas.py,sha256=wJ9QofnuHp4PjtM_IuMMBLVFVDJ4YlwcF6uQm1ooKiY,6139
88
89
  example/views.py,sha256=GQwgQcW6yoeUIDbF7-lsaZV7cs8G1S1vGVtiwVpZIQE,14338
89
- core_framework-0.12.9.dist-info/METADATA,sha256=LSnpglEoRk6NuOGA5lR2GffKmAS5Dgs7aOGj58GYTBk,13020
90
- core_framework-0.12.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
91
- core_framework-0.12.9.dist-info/entry_points.txt,sha256=MJytamxHbn0CyH3HbxiP-PqOWekjYUo2CA6EWiKWuSI,78
92
- core_framework-0.12.9.dist-info/RECORD,,
90
+ core_framework-0.12.10.dist-info/METADATA,sha256=UBWJ13i_PWqQAOHZyA0mLn6F6xhv1kpzxhqOMblfP7M,13021
91
+ core_framework-0.12.10.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
92
+ core_framework-0.12.10.dist-info/entry_points.txt,sha256=MJytamxHbn0CyH3HbxiP-PqOWekjYUo2CA6EWiKWuSI,78
93
+ core_framework-0.12.10.dist-info/RECORD,,