tai-sql 0.7.2__tar.gz → 0.7.8__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.
- {tai_sql-0.7.2 → tai_sql-0.7.8}/PKG-INFO +4 -1
- {tai_sql-0.7.2 → tai_sql-0.7.8}/pyproject.toml +5 -1
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/__init__.py +2 -0
- tai_sql-0.7.8/tai_sql/cli/commands/cmd_hash_migrate/__init__.py +3 -0
- tai_sql-0.7.8/tai_sql/cli/commands/cmd_hash_migrate/main.py +164 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_init/model.py +2 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/schemas/auth.py +11 -2
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/main.py +2 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/README.md +17 -1
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/executor.py +11 -2
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/feeder.py +24 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/transform.py +49 -7
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/__init__.py +9 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/context.py +1 -0
- tai_sql-0.7.8/tai_sql/generators/python_client/templates/base.py.jinja2 +90 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/domain/dao.py.jinja2 +25 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/domain/dtos.py.jinja2 +19 -3
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/domain/model.py.jinja2 +35 -7
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/filters.jinja2 +7 -7
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/read.jinja2 +53 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/upsert.jinja2 +1 -1
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/write.jinja2 +53 -4
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/column_analyzer.py +70 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/columns.py +8 -2
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/column.py +4 -1
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/entity.py +5 -0
- tai_sql-0.7.2/tai_sql/generators/python_client/templates/base.py.jinja2 +0 -46
- {tai_sql-0.7.2 → tai_sql-0.7.8}/LICENSE +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_backup/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_backup/main.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_deploy/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_deploy/config.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_deploy/local.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_deploy/main.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_deploy/workflow.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_feed/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_feed/main.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_generate/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_generate/funcs.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_generate/main.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_info/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_info/main.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_info/status.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_init/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_init/main.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_install/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_install/main.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_ping/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_ping/main.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_pull/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_pull/funcs.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_pull/main.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_restore/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_restore/main.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_rules/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_rules/main.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/model.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/new.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/schemas/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/schemas/base.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/schemas/chatbot.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/schemas/default.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/set_default.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/prompts.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/connectivity.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/create.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/github/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/github/auth.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/github/client.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/github/models.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/errors.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/preamble.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/services/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/services/backup.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/provider.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/recipe.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/session.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/sources.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/crypto.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/base.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/bigquery.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/capabilities.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/mysql.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/postgresql.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/sqlserver.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/errors/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/errors/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/errors/collector.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/errors/render.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/errors/types.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/collect.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/plan.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/presenter.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/records.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/base.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/assets/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/assets/cytoscape.min.js +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/builder.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/generator.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/graphviz_render.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/html_render.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/layout.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/model.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/templates/er.html.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/themes.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/calculated.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/domain/__init__.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/facade.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/aggregation.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/dataframe.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/hierarchy.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/triggers.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/vector.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/session.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/__init__.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/aggregation.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/context.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/utils/__init__.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/utils/encoders.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/utils/funcs.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/utils/hierarchy.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/utils/rls.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/vector.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/triggers/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/triggers/analyzer.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/triggers/renderer.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/triggers/steps.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/triggers/transformer.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/rules/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/rules/templates/schema.md.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/blueprint.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/build.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/defaults.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/naming.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/reader.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/templates/schema.py.jinja2 +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/types.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/writer.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/calculated.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/collector.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/constraints.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/hints.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/relation_analyzer.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/schema_analyzer.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/annotations.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/base.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/calculated.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/constraints.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/datasource.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/descriptors.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/encoders.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/enumerations.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/relations.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/table.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/trigger_api.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/triggers.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/view.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/mapping/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/mapping/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/mapping/sqlalchemy_mapper.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/constraints.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/enumeration.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/relation.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/schema.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/trigger.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/naming.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/ordering.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/registry.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/types.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/config.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/context.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/manager.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/paths.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/catalog.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/installer.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/meta.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/service.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/staleness.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/static/comandos.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/static/errores.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/static/modelo.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/schema.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/README.md +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/diff/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/diff/constraints.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/diff/indexes.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/diff/tables.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/diff/views.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/executor.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/plan/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/plan/planner.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/plan/statements.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/presenter.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/report.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/safety.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/state/__init__.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/state/local.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/state/model.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/state/normalize.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/state/remote.py +0 -0
- {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/synchronizer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tai-sql
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.8
|
|
4
4
|
Summary: SQL database management and code generation tool
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -22,9 +22,12 @@ Provides-Extra: bigquery
|
|
|
22
22
|
Provides-Extra: deploy
|
|
23
23
|
Provides-Extra: diagrams
|
|
24
24
|
Provides-Extra: encryption
|
|
25
|
+
Provides-Extra: hashing
|
|
25
26
|
Provides-Extra: mysql
|
|
26
27
|
Provides-Extra: sqlserver
|
|
27
28
|
Provides-Extra: vectors
|
|
29
|
+
Requires-Dist: bcrypt (>=4.0.0,<6.0) ; extra == "all"
|
|
30
|
+
Requires-Dist: bcrypt (>=4.0.0,<6.0) ; extra == "hashing"
|
|
28
31
|
Requires-Dist: click (>=8.2.1,<9.0)
|
|
29
32
|
Requires-Dist: cryptography (>=45.0.4,<46.0) ; extra == "all"
|
|
30
33
|
Requires-Dist: cryptography (>=45.0.4,<46.0) ; extra == "encryption"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tai-sql"
|
|
3
|
-
version = "0.7.
|
|
3
|
+
version = "0.7.8"
|
|
4
4
|
description = "SQL database management and code generation tool"
|
|
5
5
|
license = "MIT"
|
|
6
6
|
license-files = ["LICENSE"]
|
|
@@ -89,6 +89,9 @@ bigquery = ["sqlalchemy-bigquery>=1.11.0,<2.0"]
|
|
|
89
89
|
# Graphviz en el sistema (`apt install graphviz`), que pip no instala.
|
|
90
90
|
vectors = ["pgvector>=0.3.0,<1.0"]
|
|
91
91
|
encryption = ["cryptography>=45.0.4,<46.0"]
|
|
92
|
+
# `hashing`: alguna `column(hash=True)`. Lo necesitan `feed` —hashea lo que inserta— y
|
|
93
|
+
# `hash-migrate`, que reescribe una columna ya existente.
|
|
94
|
+
hashing = ["bcrypt>=4.0.0,<6.0"]
|
|
92
95
|
diagrams = ["graphviz>=0.20.3,<1.0"]
|
|
93
96
|
|
|
94
97
|
# ── Comandos concretos del CLI ────────────────────────────────────────────────
|
|
@@ -110,6 +113,7 @@ all = [
|
|
|
110
113
|
"sqlalchemy-bigquery>=1.11.0,<2.0",
|
|
111
114
|
"pgvector>=0.3.0,<1.0",
|
|
112
115
|
"cryptography>=45.0.4,<46.0",
|
|
116
|
+
"bcrypt>=4.0.0,<6.0",
|
|
113
117
|
"graphviz>=0.20.3,<1.0",
|
|
114
118
|
"tai-storage>=0.1.0",
|
|
115
119
|
"tai-alphi>=2.0.1,<3.0",
|
|
@@ -2,6 +2,7 @@ from .cmd_deploy import push
|
|
|
2
2
|
from .cmd_schema import new_schema, set_default_schema
|
|
3
3
|
from .cmd_init import init
|
|
4
4
|
from .cmd_generate import generate, run_generate
|
|
5
|
+
from .cmd_hash_migrate import hash_migrate
|
|
5
6
|
from .cmd_ping import ping
|
|
6
7
|
from .cmd_info import info
|
|
7
8
|
from .cmd_feed import feed
|
|
@@ -17,6 +18,7 @@ __all__ = [
|
|
|
17
18
|
'init',
|
|
18
19
|
'generate',
|
|
19
20
|
'run_generate',
|
|
21
|
+
'hash_migrate',
|
|
20
22
|
'ping',
|
|
21
23
|
'info',
|
|
22
24
|
'push',
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
`tai-sql hash-migrate`: pasa a hash una columna que hoy está cifrada o en claro.
|
|
4
|
+
|
|
5
|
+
Existe por una ventana que se cierra sola. Mientras lo almacenado siga siendo reversible
|
|
6
|
+
—`column(encrypt=True)` cifra con Fernet— se puede leer el valor original y volver a
|
|
7
|
+
escribirlo hasheado **sin que nadie tenga que volver a entrar**. En cuanto alguien cambie
|
|
8
|
+
la declaración a `hash=True` y empiece a escribir hashes encima, esa posibilidad
|
|
9
|
+
desaparece y solo queda el reseteo masivo de contraseñas.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import sqlalchemy as sa
|
|
15
|
+
|
|
16
|
+
from tai_sql import pm
|
|
17
|
+
from tai_sql.crypto import build_cipher
|
|
18
|
+
from tai_sql.errors import ErrorCode, SchemaDefinitionError
|
|
19
|
+
|
|
20
|
+
from ...preamble import available_schemas, load_schema
|
|
21
|
+
from ..prompts import select_one
|
|
22
|
+
|
|
23
|
+
#: Prefijos con los que empieza un hash de bcrypt. Lo que ya los lleva está migrado.
|
|
24
|
+
PREFIJOS_BCRYPT = ('$2a$', '$2b$', '$2x$', '$2y$')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ya_esta_hasheado(valor: str) -> bool:
|
|
28
|
+
return isinstance(valor, str) and valor.startswith(PREFIJOS_BCRYPT)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@click.command('hash-migrate')
|
|
32
|
+
@click.option('--schema', '-s', help='Nombre del esquema')
|
|
33
|
+
@click.option('--table', '-t', required=True, help='Tabla que contiene la columna')
|
|
34
|
+
@click.option('--column', '-c', 'columna', required=True, help='Columna a migrar')
|
|
35
|
+
@click.option('--dry-run', '-d', is_flag=True, help='Cuenta lo que cambiaría, sin escribir')
|
|
36
|
+
def hash_migrate(schema: str, table: str, columna: str, dry_run: bool):
|
|
37
|
+
"""Convierte a hash los valores de una columna declarada con hash=True."""
|
|
38
|
+
if not schema:
|
|
39
|
+
schema = select_one('Selecciona el esquema', available_schemas())
|
|
40
|
+
load_schema(schema)
|
|
41
|
+
|
|
42
|
+
modelo = pm.db.schema_model
|
|
43
|
+
entidad = next(
|
|
44
|
+
(e for e in modelo.entities if e.tablename == table), None
|
|
45
|
+
)
|
|
46
|
+
if entidad is None:
|
|
47
|
+
raise SchemaDefinitionError(
|
|
48
|
+
message=f"La tabla '{table}' no existe en el schema '{schema}'",
|
|
49
|
+
solution=(
|
|
50
|
+
'Usa el nombre de tabla tal y como lo declara `__tablename__`: '
|
|
51
|
+
+ ', '.join(sorted(e.tablename for e in modelo.entities))
|
|
52
|
+
),
|
|
53
|
+
code=ErrorCode.MISSING_TABLE,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
col = entidad.columns.get(columna)
|
|
57
|
+
if col is None:
|
|
58
|
+
raise SchemaDefinitionError(
|
|
59
|
+
message=f"La columna '{columna}' no existe en '{table}'",
|
|
60
|
+
solution='Columnas disponibles: ' + ', '.join(sorted(entidad.columns)),
|
|
61
|
+
code=ErrorCode.INVALID_COLUMN,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if not col.hash:
|
|
65
|
+
raise SchemaDefinitionError(
|
|
66
|
+
message=f"La columna '{table}.{columna}' no está declarada con hash=True",
|
|
67
|
+
solution=(
|
|
68
|
+
'Migrar una columna a hash es de ida: primero se declara '
|
|
69
|
+
f'`{columna}: col[str] = column(hash=True)` en el schema y se ejecuta '
|
|
70
|
+
'`tai-sql push`, y solo después se migran los valores'
|
|
71
|
+
),
|
|
72
|
+
code=ErrorCode.INVALID_COLUMN,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
claves = [c.name for c in entidad.columns.values() if c.is_primary_key]
|
|
76
|
+
if not claves:
|
|
77
|
+
raise SchemaDefinitionError(
|
|
78
|
+
message=f"La tabla '{table}' no tiene clave primaria",
|
|
79
|
+
solution='Sin ella no hay forma de reescribir cada fila por separado',
|
|
80
|
+
code=ErrorCode.INVALID_CONSTRAINT,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
_migrar(schema, entidad.tablename, columna, claves, dry_run)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _migrar(schema: str, tabla: str, columna: str, claves: list[str], dry_run: bool) -> None:
|
|
87
|
+
import bcrypt
|
|
88
|
+
|
|
89
|
+
cipher = build_cipher(pm.db.secret_key_name)
|
|
90
|
+
if cipher is None:
|
|
91
|
+
# Sin cifrador no se puede revertir nada: lo que haya se tratará como valor en
|
|
92
|
+
# claro. Es correcto si la columna nunca estuvo cifrada, y una pérdida silenciosa
|
|
93
|
+
# si lo estuvo, así que se dice antes de tocar nada.
|
|
94
|
+
click.echo(click.style(
|
|
95
|
+
f"⚠️ No hay cifrador: falta cryptography o la variable "
|
|
96
|
+
f"'{pm.db.secret_key_name}'. Si la columna estaba cifrada, aborta y "
|
|
97
|
+
f"configúrala; si estaba en claro, puedes continuar.",
|
|
98
|
+
fg='yellow',
|
|
99
|
+
))
|
|
100
|
+
if not dry_run and not click.confirm('¿Continuar?', default=False):
|
|
101
|
+
raise click.Abort()
|
|
102
|
+
|
|
103
|
+
metadata = sa.MetaData()
|
|
104
|
+
tabla_sa = sa.Table(
|
|
105
|
+
tabla, metadata,
|
|
106
|
+
*[sa.Column(nombre, sa.String, primary_key=True) for nombre in claves],
|
|
107
|
+
sa.Column(columna, sa.String),
|
|
108
|
+
schema=pm.db.schema_name,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
engine = pm.db.provider.engine
|
|
112
|
+
cifrados = planos = ya_hechos = nulos = 0
|
|
113
|
+
|
|
114
|
+
click.echo(f"🔐 Migrando {tabla}.{columna} a hash" + (' (dry-run)' if dry_run else ''))
|
|
115
|
+
|
|
116
|
+
with engine.begin() as conexion:
|
|
117
|
+
filas = conexion.execute(
|
|
118
|
+
sa.select(*[tabla_sa.c[n] for n in claves], tabla_sa.c[columna])
|
|
119
|
+
).all()
|
|
120
|
+
|
|
121
|
+
for fila in filas:
|
|
122
|
+
valor = fila[-1]
|
|
123
|
+
identidad = dict(zip(claves, fila[:-1]))
|
|
124
|
+
|
|
125
|
+
if valor is None:
|
|
126
|
+
nulos += 1
|
|
127
|
+
continue
|
|
128
|
+
if ya_esta_hasheado(valor):
|
|
129
|
+
ya_hechos += 1
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
if cipher is None:
|
|
134
|
+
raise ValueError('sin cifrador')
|
|
135
|
+
original = cipher.decrypt(valor.encode()).decode()
|
|
136
|
+
cifrados += 1
|
|
137
|
+
except Exception:
|
|
138
|
+
# Ni hash ni cifrado: es el valor en claro, y hashearlo es justo lo que
|
|
139
|
+
# esta migración viene a hacer.
|
|
140
|
+
original = valor
|
|
141
|
+
planos += 1
|
|
142
|
+
|
|
143
|
+
if not dry_run:
|
|
144
|
+
nuevo = bcrypt.hashpw(original.encode(), bcrypt.gensalt()).decode()
|
|
145
|
+
conexion.execute(
|
|
146
|
+
sa.update(tabla_sa)
|
|
147
|
+
.where(sa.and_(*[tabla_sa.c[n] == v for n, v in identidad.items()]))
|
|
148
|
+
.values(**{columna: nuevo})
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
click.echo()
|
|
152
|
+
click.echo(f" Descifradas y hasheadas : {cifrados}")
|
|
153
|
+
click.echo(f" En claro, hasheadas : {planos}")
|
|
154
|
+
click.echo(f" Ya estaban hasheadas : {ya_hechos}")
|
|
155
|
+
click.echo(f" Nulas, sin tocar : {nulos}")
|
|
156
|
+
click.echo()
|
|
157
|
+
|
|
158
|
+
if dry_run:
|
|
159
|
+
click.echo(click.style('Nada escrito: era una simulación.', fg='yellow'))
|
|
160
|
+
else:
|
|
161
|
+
click.echo(click.style(
|
|
162
|
+
f'✅ {cifrados + planos} filas migradas. Volver a ejecutarlo no cambia nada.',
|
|
163
|
+
fg='green',
|
|
164
|
+
))
|
|
@@ -52,6 +52,8 @@ PYPROJECT_TEMPLATE = dedent('''\
|
|
|
52
52
|
|
|
53
53
|
# Funcionalidades opcionales
|
|
54
54
|
encryption = ["cryptography (>=45.0.4,<46.0)"]
|
|
55
|
+
# column(hash=True): el cliente hashea al escribir y verifica al comprobar.
|
|
56
|
+
hashing = ["bcrypt (>=4.0.0,<6.0)"]
|
|
55
57
|
vectors = ["pgvector (>=0.4.0,<1.0)"]
|
|
56
58
|
# Encoders de columnas vectoriales. El cliente generado los implementa sobre
|
|
57
59
|
# langchain, así que estas son las librerías que importa de verdad; deja solo la
|
|
@@ -96,7 +96,7 @@ class AuthSchema(SchemaTemplate):
|
|
|
96
96
|
|
|
97
97
|
first_name: col[str | None] = column(description="Nombre del usuario")
|
|
98
98
|
last_name: col[str | None] = column(description="Apellido del usuario")
|
|
99
|
-
password: col[str] = column(
|
|
99
|
+
password: col[str] = column(hash=True, description="Contraseña hasheada: solo se puede verificar, no leer")
|
|
100
100
|
email: col[str | None] = column(description="Correo electrónico")
|
|
101
101
|
is_active: col[bool] = column(default=True, description="Usuario activo")
|
|
102
102
|
session_id: col[str | None] = column(description="ID de sesión actual (para control de concurrencia)")
|
|
@@ -120,8 +120,11 @@ class AuthSchema(SchemaTemplate):
|
|
|
120
120
|
)
|
|
121
121
|
]
|
|
122
122
|
|
|
123
|
+
# Opcional: nombre y apellidos lo son, y sin ninguno de los dos no hay nombre
|
|
124
|
+
# completo que calcular. Declarada `-> str`, la columna era NOT NULL y crear un
|
|
125
|
+
# usuario sin nombre fallaba con una violación de integridad.
|
|
123
126
|
@calculated_column
|
|
124
|
-
def full_name(self) -> str:
|
|
127
|
+
def full_name(self) -> Optional[str]:
|
|
125
128
|
return f"{self.first_name or ''} {self.last_name or ''}".strip() if self.first_name or self.last_name else None
|
|
126
129
|
|
|
127
130
|
|
|
@@ -187,6 +190,10 @@ class AuthSchema(SchemaTemplate):
|
|
|
187
190
|
class UserRole(Table):
|
|
188
191
|
"""Relación M2M entre User y Role"""
|
|
189
192
|
__tablename__ = "user_role"
|
|
193
|
+
# Un usuario tiene un rol una vez. Es además la clave natural por la que
|
|
194
|
+
# `tai-sql feed` reconoce la fila: sin ella se negaba a alimentar la tabla,
|
|
195
|
+
# y un proyecto nuevo se quedaba sin el usuario admin.
|
|
196
|
+
__constraints__ = unique_constraint("user_name", "role_name", "realm_name")
|
|
190
197
|
|
|
191
198
|
id: col[bigint] = column(primary_key=True, autoincrement=True)
|
|
192
199
|
user_name: col[str] = column(description="ID del usuario")
|
|
@@ -206,6 +213,8 @@ class AuthSchema(SchemaTemplate):
|
|
|
206
213
|
class RolePermission(Table):
|
|
207
214
|
"""Relación M2M entre Role y Permission"""
|
|
208
215
|
__tablename__ = "role_permission"
|
|
216
|
+
# Un rol tiene un permiso una vez; y es la clave natural del feed, como en UserRole.
|
|
217
|
+
__constraints__ = unique_constraint("role_name", "permission_name", "realm_name")
|
|
209
218
|
|
|
210
219
|
id: col[bigint] = column(primary_key=True, autoincrement=True)
|
|
211
220
|
role_name: col[str] = column(description="ID del rol")
|
|
@@ -15,6 +15,7 @@ hacer.
|
|
|
15
15
|
import click
|
|
16
16
|
|
|
17
17
|
from .commands import (
|
|
18
|
+
hash_migrate,
|
|
18
19
|
# deploy_config,
|
|
19
20
|
new_schema,
|
|
20
21
|
set_default_schema,
|
|
@@ -50,6 +51,7 @@ cli.add_command(new_schema)
|
|
|
50
51
|
cli.add_command(set_default_schema)
|
|
51
52
|
cli.add_command(init)
|
|
52
53
|
cli.add_command(generate)
|
|
54
|
+
cli.add_command(hash_migrate)
|
|
53
55
|
cli.add_command(ping)
|
|
54
56
|
cli.add_command(info)
|
|
55
57
|
cli.add_command(push)
|
|
@@ -11,7 +11,7 @@ collect.py ¿qué filas declara el schema? invoca feed(), sin BD
|
|
|
11
11
|
resolve — no existe: la resolución de referencias es del ejecutor,
|
|
12
12
|
que es quien conoce las claves que acaba de generar la BD
|
|
13
13
|
plan.py ¿en qué orden, con qué clave? compone; no implementa ni una ni otra
|
|
14
|
-
transform.py cifrado, enums, defaults, vectores
|
|
14
|
+
transform.py cifrado, hash, enums, defaults, vectores
|
|
15
15
|
executor.py aplicarlo, en una transacción
|
|
16
16
|
presenter.py todo el click.echo
|
|
17
17
|
feeder.py la fachada
|
|
@@ -42,6 +42,22 @@ result = feeder.execute(plan) # 4. aplicarlas
|
|
|
42
42
|
|
|
43
43
|
Sin presentador no dice nada, que es como lo usan los tests.
|
|
44
44
|
|
|
45
|
+
## Las columnas protegidas
|
|
46
|
+
|
|
47
|
+
Una columna `encrypt=True` se cifra con la clave de `tai_sql/crypto.py`, y una `hash=True` se
|
|
48
|
+
hashea con el mismo bcrypt que usa el cliente generado. Sin la clave, o sin bcrypt, el feed **se
|
|
49
|
+
niega a empezar** (`_check_encryption`, `_check_hashing`): insertar en claro no es una opción.
|
|
50
|
+
|
|
51
|
+
**Una columna hasheada se escribe al crear la fila y no se reescribe en un upsert.** Lo que
|
|
52
|
+
declara un `feed()` es su valor inicial —la contraseña con la que nace el usuario admin del
|
|
53
|
+
schema `auth`—, no el vigente. Si el upsert la reescribiera, volver a lanzar `tai-sql feed`
|
|
54
|
+
devolvería a su valor de fábrica la contraseña que alguien ya había cambiado; y como cada hash
|
|
55
|
+
lleva su salt, además cambiaría la fila en cada ejecución. Un valor que ya es un hash bcrypt se
|
|
56
|
+
inserta tal cual, así que un `feed()` puede declarar uno precalculado.
|
|
57
|
+
|
|
58
|
+
Hasta la 0.7.7 el feed cifraba pero **no hasheaba**: toda columna `hash=True` se insertaba en
|
|
59
|
+
claro, incluida la contraseña del admin de cualquier proyecto con autenticación Database.
|
|
60
|
+
|
|
45
61
|
## Las cuatro decisiones que lo ordenan
|
|
46
62
|
|
|
47
63
|
### 1. No estrena ninguna pieza cuyo propósito ya tuviera dueño
|
|
@@ -371,11 +371,20 @@ class FeedExecutor:
|
|
|
371
371
|
return valor
|
|
372
372
|
|
|
373
373
|
def _update_columns(self, plan: FeedPlan, rows) -> list[str]:
|
|
374
|
-
"""
|
|
374
|
+
"""
|
|
375
|
+
Qué columnas se actualizan si la fila ya existía: todas menos la clave y las hasheadas.
|
|
376
|
+
|
|
377
|
+
Una columna hasheada se escribe al crear la fila y no se reescribe después. Lo que
|
|
378
|
+
declara un `feed()` es su valor **inicial** —la contraseña con la que nace el usuario
|
|
379
|
+
admin—, no el vigente: reescribirla en cada `tai-sql feed` devolvería a su valor de
|
|
380
|
+
fábrica la contraseña que alguien ya había cambiado. Y como cada hash lleva su propio
|
|
381
|
+
salt, además cambiaría la fila en cada ejecución aunque nada hubiera cambiado.
|
|
382
|
+
"""
|
|
375
383
|
if not plan.conflict_columns:
|
|
376
384
|
return []
|
|
385
|
+
hasheadas = {c.name for c in plan.table.hashed_columns}
|
|
377
386
|
columnas = {name for row in rows for name in row}
|
|
378
|
-
return [c for c in columnas if c not in plan.conflict_columns]
|
|
387
|
+
return [c for c in columnas if c not in plan.conflict_columns and c not in hasheadas]
|
|
379
388
|
|
|
380
389
|
def _table_of(self, plan: FeedPlan) -> SQLAlchemyTable:
|
|
381
390
|
"""La `Table` de SQLAlchemy de una tabla del plan."""
|
|
@@ -80,6 +80,7 @@ class Feeder:
|
|
|
80
80
|
reconocerse. Sin esto es un error: ver `_check_conflict_keys`.
|
|
81
81
|
"""
|
|
82
82
|
self._check_encryption()
|
|
83
|
+
self._check_hashing()
|
|
83
84
|
|
|
84
85
|
rows = collect(self.schema, table_filter, on_warning=self.presenter.warning)
|
|
85
86
|
plan = build_plan(self.schema, rows)
|
|
@@ -157,6 +158,29 @@ class Feeder:
|
|
|
157
158
|
code=ErrorCode.MISSING_ENV_VAR,
|
|
158
159
|
)
|
|
159
160
|
|
|
161
|
+
def _check_hashing(self) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Sin bcrypt, una columna hasheada no se puede escribir. Se corta antes de empezar.
|
|
164
|
+
|
|
165
|
+
Si se dejara llegar al primer insert, las tablas anteriores ya estarían
|
|
166
|
+
alimentadas; y guardarla en claro, que es lo que hacía el feed, no es una opción.
|
|
167
|
+
"""
|
|
168
|
+
hasheadas = transform.hashed_columns_by_table(self.schema.tables.values())
|
|
169
|
+
if not hasheadas:
|
|
170
|
+
return
|
|
171
|
+
try:
|
|
172
|
+
import bcrypt # noqa: F401
|
|
173
|
+
except ImportError:
|
|
174
|
+
detalle = '\n'.join(
|
|
175
|
+
f" • {tabla}: {', '.join(columnas)}"
|
|
176
|
+
for tabla, columnas in sorted(hasheadas.items())
|
|
177
|
+
)
|
|
178
|
+
raise TaiSqlEnvironmentError(
|
|
179
|
+
message=f"El schema declara columnas hasheadas y bcrypt no está instalado:\n{detalle}",
|
|
180
|
+
solution="Instala el extra que las hashea: pip install 'tai-sql[hashing]'",
|
|
181
|
+
code=ErrorCode.OPERATION_FAILED,
|
|
182
|
+
)
|
|
183
|
+
|
|
160
184
|
def _check_conflict_keys(self, plan: Sequence[FeedPlan]) -> None:
|
|
161
185
|
"""
|
|
162
186
|
Una tabla sin clave de conflicto no se puede alimentar dos veces.
|
|
@@ -4,10 +4,11 @@ Lo que le pasa a una fila entre que se declara y se inserta.
|
|
|
4
4
|
|
|
5
5
|
CAPA 3 — transformación (`tai_sql/feed/`).
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
clave de cifrado de `tai_sql/crypto.py
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
Cinco pasos, y **ninguno inventa una regla nueva**: los defaults salen del modelo, la
|
|
8
|
+
clave de cifrado de `tai_sql/crypto.py`, el hash es el mismo bcrypt que usa el cliente
|
|
9
|
+
generado y la forma de un valor enumerado es la de siempre. Están juntos aquí porque los
|
|
10
|
+
cinco son lo mismo —una fila entra y sale—, y porque así el ejecutor no sabe nada de
|
|
11
|
+
cifrado, hashes ni enums: solo manda la fila a la base de datos.
|
|
11
12
|
"""
|
|
12
13
|
from __future__ import annotations
|
|
13
14
|
|
|
@@ -19,7 +20,7 @@ from ..errors import ErrorCode, SchemaDefinitionError
|
|
|
19
20
|
if TYPE_CHECKING:
|
|
20
21
|
from ..orm.model import TableModel
|
|
21
22
|
|
|
22
|
-
__all__ = ['prepare', 'check_vectors', 'encrypted_columns_by_table']
|
|
23
|
+
__all__ = ['prepare', 'check_vectors', 'encrypted_columns_by_table', 'hashed_columns_by_table']
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def prepare(
|
|
@@ -37,7 +38,8 @@ def prepare(
|
|
|
37
38
|
row = _fill_defaults(table, row)
|
|
38
39
|
row = _drop_generated(table, row)
|
|
39
40
|
row = _normalize_enums(row)
|
|
40
|
-
|
|
41
|
+
row = _encrypt(table, row, cipher)
|
|
42
|
+
return _hash(table, row)
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
def check_vectors(table: TableModel, values: Mapping[str, Any]) -> None:
|
|
@@ -89,8 +91,17 @@ def encrypted_columns_by_table(tables) -> dict[str, list[str]]:
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
|
|
94
|
+
def hashed_columns_by_table(tables) -> dict[str, list[str]]:
|
|
95
|
+
"""Las columnas hasheadas de cada tabla, para comprobar de antemano que se puede hashear."""
|
|
96
|
+
return {
|
|
97
|
+
table.tablename: [c.name for c in table.hashed_columns]
|
|
98
|
+
for table in tables
|
|
99
|
+
if table.hashed_columns
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
92
103
|
# ─────────────────────────────────────────────────────────────────────────
|
|
93
|
-
# Los
|
|
104
|
+
# Los cinco pasos
|
|
94
105
|
# ─────────────────────────────────────────────────────────────────────────
|
|
95
106
|
|
|
96
107
|
def _fill_defaults(table: TableModel, row: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -160,3 +171,34 @@ def _encrypt(
|
|
|
160
171
|
)
|
|
161
172
|
for name, value in row.items()
|
|
162
173
|
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
#: Con esto empieza un hash bcrypt. Un valor que ya lo es se inserta tal cual, así que un
|
|
177
|
+
#: `feed()` puede declarar un hash precalculado en vez de la contraseña.
|
|
178
|
+
PREFIJOS_BCRYPT = ('$2a$', '$2b$', '$2x$', '$2y$')
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _hash(table: TableModel, row: dict[str, Any]) -> dict[str, Any]:
|
|
182
|
+
"""
|
|
183
|
+
Hashea las columnas marcadas con `hash=True`, con el mismo bcrypt que el cliente generado.
|
|
184
|
+
|
|
185
|
+
No lo hacía: el feed cifraba las columnas `encrypt=True` y dejaba las hasheadas **en
|
|
186
|
+
claro**. El usuario `admin` que el schema `auth` crea con su `feed()` quedaba con la
|
|
187
|
+
contraseña legible en la base de datos, y `verify_password` no podía comprobarla porque
|
|
188
|
+
lo guardado no era un hash.
|
|
189
|
+
"""
|
|
190
|
+
hasheadas = {c.name for c in table.hashed_columns}
|
|
191
|
+
if not hasheadas:
|
|
192
|
+
return row
|
|
193
|
+
|
|
194
|
+
import bcrypt # comprobado de antemano por el feeder: ver `_check_hashing`
|
|
195
|
+
|
|
196
|
+
def _hashear(valor):
|
|
197
|
+
if valor is None or (isinstance(valor, str) and valor.startswith(PREFIJOS_BCRYPT)):
|
|
198
|
+
return valor
|
|
199
|
+
return bcrypt.hashpw(str(valor).encode(), bcrypt.gensalt()).decode()
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
name: _hashear(value) if name in hasheadas else value
|
|
203
|
+
for name, value in row.items()
|
|
204
|
+
}
|
|
@@ -259,6 +259,10 @@ class PythonClientGenerator(BaseGenerator):
|
|
|
259
259
|
def has_encrypted_columns(self) -> bool:
|
|
260
260
|
return any(entity.encrypted_columns for entity in self.schema.entities)
|
|
261
261
|
|
|
262
|
+
@property
|
|
263
|
+
def has_hashed_columns(self) -> bool:
|
|
264
|
+
return any(entity.hashed_columns for entity in self.schema.entities)
|
|
265
|
+
|
|
262
266
|
def validate_encryption_setup(self):
|
|
263
267
|
if not self.has_encrypted_columns:
|
|
264
268
|
return
|
|
@@ -294,6 +298,9 @@ class PythonClientGenerator(BaseGenerator):
|
|
|
294
298
|
def _model_has_encrypt(self, entity: EntityModel) -> bool:
|
|
295
299
|
return bool(entity.encrypted_columns)
|
|
296
300
|
|
|
301
|
+
def _model_has_hash(self, entity: EntityModel) -> bool:
|
|
302
|
+
return bool(entity.hashed_columns)
|
|
303
|
+
|
|
297
304
|
def _model_has_calculated(self, entity: EntityModel) -> bool:
|
|
298
305
|
return any(col.is_calculated for col in entity.columns.values())
|
|
299
306
|
|
|
@@ -371,6 +378,7 @@ class PythonClientGenerator(BaseGenerator):
|
|
|
371
378
|
code = template.render(
|
|
372
379
|
has_dict=has_dict,
|
|
373
380
|
has_encrypted_columns=self.has_encrypted_columns,
|
|
381
|
+
has_hashed_columns=self.has_hashed_columns,
|
|
374
382
|
secret_key_name=self.schema.secret_key_name,
|
|
375
383
|
)
|
|
376
384
|
(schema_dir / '_base.py').write_text(code, encoding='utf-8')
|
|
@@ -480,6 +488,7 @@ class PythonClientGenerator(BaseGenerator):
|
|
|
480
488
|
is_postgres=getattr(self.provider, 'drivername', None) == 'postgresql',
|
|
481
489
|
schema_name=self.schema.name,
|
|
482
490
|
encrypt=self._model_has_encrypt(entity),
|
|
491
|
+
hash=self._model_has_hash(entity),
|
|
483
492
|
has_calculated=self._model_has_calculated(entity),
|
|
484
493
|
import_prefix=self.import_layout.domain_prefix,
|
|
485
494
|
cross_domain_infix=self.import_layout.cross_domain_infix,
|
|
@@ -215,6 +215,7 @@ class TemplateContextBuilder:
|
|
|
215
215
|
'is_foreign_key': column.is_foreign_key,
|
|
216
216
|
'default': constraints['default'],
|
|
217
217
|
'encrypt': column.encrypt,
|
|
218
|
+
'hash': column.hash,
|
|
218
219
|
'constraints': constraints,
|
|
219
220
|
'description': (
|
|
220
221
|
column.description or f"Campo {column.name} de la tabla {tablename}"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Este archivo ha sido generado automáticamente por tai-sql
|
|
2
|
+
# No modifiques este archivo directamente
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from typing import List, Optional{% if has_dict %}, Dict, Any{% endif %}
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
8
|
+
{% if has_encrypted_columns %}
|
|
9
|
+
import os
|
|
10
|
+
from cryptography.fernet import Fernet
|
|
11
|
+
import base64
|
|
12
|
+
{% endif %}
|
|
13
|
+
{% if has_hashed_columns %}
|
|
14
|
+
import bcrypt
|
|
15
|
+
{% endif %}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Base(DeclarativeBase):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
{% if has_encrypted_columns %}
|
|
22
|
+
|
|
23
|
+
# Configuración de encriptación
|
|
24
|
+
_secret_key = os.getenv('{{ secret_key_name }}')
|
|
25
|
+
if not _secret_key:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"Variable de entorno '{{ secret_key_name }}' no encontrada para encriptación. "
|
|
28
|
+
f"Por favor, configure la variable de entorno '{{ secret_key_name }}' en su sistema "
|
|
29
|
+
f"con una clave secreta segura antes de ejecutar la aplicación. "
|
|
30
|
+
f"Ejemplo: export {{ secret_key_name }}='su-clave-secreta-de-32-caracteres-aqui'"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Generar clave Fernet desde la clave secreta
|
|
34
|
+
_fernet_key = base64.urlsafe_b64encode(_secret_key.encode()[:32].ljust(32, b'\0'))
|
|
35
|
+
_cipher = Fernet(_fernet_key)
|
|
36
|
+
|
|
37
|
+
def encrypt_value(value: str) -> str:
|
|
38
|
+
"""Encripta un valor string"""
|
|
39
|
+
if value is None:
|
|
40
|
+
return None
|
|
41
|
+
return _cipher.encrypt(value.encode()).decode()
|
|
42
|
+
|
|
43
|
+
def decrypt_value(value: str) -> str:
|
|
44
|
+
"""Desencripta un valor string"""
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
return _cipher.decrypt(value.encode()).decode()
|
|
48
|
+
|
|
49
|
+
{% endif %}
|
|
50
|
+
{% if has_hashed_columns %}
|
|
51
|
+
|
|
52
|
+
# ── Hashing ──────────────────────────────────────────────────────────────────
|
|
53
|
+
#
|
|
54
|
+
# Distinto del cifrado de arriba y no intercambiable: esto es de ida. El valor original
|
|
55
|
+
# no queda en ninguna parte, así que no se puede leer — solo comprobar si un candidato
|
|
56
|
+
# coincide. No lleva clave: el salt va dentro del propio hash, y por eso dos filas con
|
|
57
|
+
# el mismo valor guardan textos distintos.
|
|
58
|
+
|
|
59
|
+
def hash_value(value: str) -> str:
|
|
60
|
+
"""Hashea un valor. Devuelve None si el valor es None."""
|
|
61
|
+
if value is None:
|
|
62
|
+
return None
|
|
63
|
+
return bcrypt.hashpw(value.encode(), bcrypt.gensalt()).decode()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
#: Un hash bcrypt válido, del mismo coste que `gensalt()` por defecto (12), que no
|
|
67
|
+
#: corresponde a nada. Sirve para comprobar un candidato cuando la fila no existe: sin
|
|
68
|
+
#: él, «no existe» respondía al instante y «no coincide» tras el bcrypt (~200 ms), y esa
|
|
69
|
+
#: diferencia basta para averiguar desde fuera qué usuarios hay. Si algún día se sube el
|
|
70
|
+
#: coste de `gensalt()`, hay que regenerarlo con el mismo.
|
|
71
|
+
HASH_DE_RELLENO = "$2b$12$diGTNtGT.nEOuDdpfJHsD.4nO.d3Pk41jUWW9/OizA//1Aa3So30S"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def verify_hash(value: str, hashed: str) -> bool:
|
|
75
|
+
"""
|
|
76
|
+
Comprueba si un valor corresponde a un hash.
|
|
77
|
+
|
|
78
|
+
Devuelve False ante cualquier entrada que no sea comparable —None, o un hash con un
|
|
79
|
+
formato que bcrypt no reconoce, como el de una fila que aún no se ha migrado— en vez
|
|
80
|
+
de propagar la excepción: quien pregunta esto está autenticando, y un error ahí es un
|
|
81
|
+
«no» y no una caída.
|
|
82
|
+
"""
|
|
83
|
+
if value is None or hashed is None:
|
|
84
|
+
return False
|
|
85
|
+
try:
|
|
86
|
+
return bcrypt.checkpw(value.encode(), hashed.encode())
|
|
87
|
+
except (ValueError, TypeError):
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
{% endif %}
|
{tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/domain/dao.py.jinja2
RENAMED
|
@@ -85,6 +85,9 @@ from {{ import_prefix }}_shared import (
|
|
|
85
85
|
)
|
|
86
86
|
{% endif %}
|
|
87
87
|
from {{ import_prefix }}_session import {{ session_manager_var }}
|
|
88
|
+
{% if model.columns | selectattr('hash') | list %}
|
|
89
|
+
from {{ import_prefix }}_base import verify_hash, HASH_DE_RELLENO
|
|
90
|
+
{% endif %}
|
|
88
91
|
{# ── Collect cross-DAO targets (relations + trigger cross-table ops) ── #}
|
|
89
92
|
{% set _cross_dao_targets = [] %}
|
|
90
93
|
{% for rel in model.relations | default([]) %}
|
|
@@ -112,6 +115,28 @@ if TYPE_CHECKING:
|
|
|
112
115
|
|
|
113
116
|
# Logger
|
|
114
117
|
logger = Alphi.get_logger_by_name("{{ logger_name }}")
|
|
118
|
+
{# Las columnas cifradas o hasheadas. Su valor no puede escribirse en un log —el de una
|
|
119
|
+
cifrada es justo lo que se protege, el de una hasheada es una contraseña en claro— ni
|
|
120
|
+
llegar a la base de datos sin pasar por su setter, que es quien cifra o hashea. Se
|
|
121
|
+
calcula aquí, a nivel de plantilla, porque lo usan los macros incluidos de escritura. -#}
|
|
122
|
+
{% set columnas_protegidas = (model.columns | selectattr('encrypt') | map(attribute='name') | list)
|
|
123
|
+
+ (model.columns | selectattr('hash') | map(attribute='name') | list) %}
|
|
124
|
+
{% if columnas_protegidas %}
|
|
125
|
+
|
|
126
|
+
#: Columnas cuyo valor no se escribe en los logs y no se actualiza con un UPDATE directo:
|
|
127
|
+
#: las cifradas y las hasheadas. Ver `_para_log` y `update_many`.
|
|
128
|
+
_COLUMNAS_PROTEGIDAS = frozenset({{ columnas_protegidas | tojson }})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _para_log(valores: dict) -> dict:
|
|
132
|
+
"""
|
|
133
|
+
Los valores de una escritura, listos para un log: los protegidos, enmascarados.
|
|
134
|
+
|
|
135
|
+
Los logs de `update` y `update_many` volcaban los valores tal cual, así que cambiar
|
|
136
|
+
una contraseña la dejaba en claro en el log de la aplicación.
|
|
137
|
+
"""
|
|
138
|
+
return {clave: ('***' if clave in _COLUMNAS_PROTEGIDAS else valor) for clave, valor in valores.items()}
|
|
139
|
+
{% endif %}
|
|
115
140
|
|
|
116
141
|
{% set vector_cols_with_encoder = model.columns | selectattr('type', 'equalto', 'vector') | selectattr('has_encoder') | list %}
|
|
117
142
|
{% if vector_cols_with_encoder %}
|