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.
Files changed (227) hide show
  1. {tai_sql-0.7.2 → tai_sql-0.7.8}/PKG-INFO +4 -1
  2. {tai_sql-0.7.2 → tai_sql-0.7.8}/pyproject.toml +5 -1
  3. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/__init__.py +2 -0
  4. tai_sql-0.7.8/tai_sql/cli/commands/cmd_hash_migrate/__init__.py +3 -0
  5. tai_sql-0.7.8/tai_sql/cli/commands/cmd_hash_migrate/main.py +164 -0
  6. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_init/model.py +2 -0
  7. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/schemas/auth.py +11 -2
  8. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/main.py +2 -0
  9. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/README.md +17 -1
  10. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/executor.py +11 -2
  11. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/feeder.py +24 -0
  12. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/transform.py +49 -7
  13. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/__init__.py +9 -0
  14. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/context.py +1 -0
  15. tai_sql-0.7.8/tai_sql/generators/python_client/templates/base.py.jinja2 +90 -0
  16. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/domain/dao.py.jinja2 +25 -0
  17. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/domain/dtos.py.jinja2 +19 -3
  18. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/domain/model.py.jinja2 +35 -7
  19. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/filters.jinja2 +7 -7
  20. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/read.jinja2 +53 -0
  21. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/upsert.jinja2 +1 -1
  22. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/write.jinja2 +53 -4
  23. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/column_analyzer.py +70 -0
  24. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/columns.py +8 -2
  25. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/column.py +4 -1
  26. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/entity.py +5 -0
  27. tai_sql-0.7.2/tai_sql/generators/python_client/templates/base.py.jinja2 +0 -46
  28. {tai_sql-0.7.2 → tai_sql-0.7.8}/LICENSE +0 -0
  29. {tai_sql-0.7.2 → tai_sql-0.7.8}/README.md +0 -0
  30. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/__init__.py +0 -0
  31. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_backup/__init__.py +0 -0
  32. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_backup/main.py +0 -0
  33. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_deploy/__init__.py +0 -0
  34. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_deploy/config.py +0 -0
  35. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_deploy/local.py +0 -0
  36. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_deploy/main.py +0 -0
  37. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_deploy/workflow.py +0 -0
  38. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_feed/__init__.py +0 -0
  39. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_feed/main.py +0 -0
  40. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_generate/__init__.py +0 -0
  41. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_generate/funcs.py +0 -0
  42. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_generate/main.py +0 -0
  43. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_info/__init__.py +0 -0
  44. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_info/main.py +0 -0
  45. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_info/status.py +0 -0
  46. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_init/__init__.py +0 -0
  47. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_init/main.py +0 -0
  48. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_install/__init__.py +0 -0
  49. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_install/main.py +0 -0
  50. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_ping/__init__.py +0 -0
  51. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_ping/main.py +0 -0
  52. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_pull/__init__.py +0 -0
  53. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_pull/funcs.py +0 -0
  54. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_pull/main.py +0 -0
  55. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_restore/__init__.py +0 -0
  56. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_restore/main.py +0 -0
  57. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_rules/__init__.py +0 -0
  58. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_rules/main.py +0 -0
  59. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/__init__.py +0 -0
  60. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/model.py +0 -0
  61. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/new.py +0 -0
  62. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/schemas/__init__.py +0 -0
  63. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/schemas/base.py +0 -0
  64. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/schemas/chatbot.py +0 -0
  65. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/schemas/default.py +0 -0
  66. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/cmd_schema/set_default.py +0 -0
  67. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/prompts.py +0 -0
  68. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/__init__.py +0 -0
  69. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/connectivity.py +0 -0
  70. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/create.py +0 -0
  71. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/github/__init__.py +0 -0
  72. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/github/auth.py +0 -0
  73. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/github/client.py +0 -0
  74. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/commands/utils/github/models.py +0 -0
  75. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/errors.py +0 -0
  76. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/preamble.py +0 -0
  77. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/services/__init__.py +0 -0
  78. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/cli/services/backup.py +0 -0
  79. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/README.md +0 -0
  80. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/__init__.py +0 -0
  81. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/provider.py +0 -0
  82. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/recipe.py +0 -0
  83. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/session.py +0 -0
  84. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/connection/sources.py +0 -0
  85. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/crypto.py +0 -0
  86. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/__init__.py +0 -0
  87. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/base.py +0 -0
  88. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/bigquery.py +0 -0
  89. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/capabilities.py +0 -0
  90. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/mysql.py +0 -0
  91. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/postgresql.py +0 -0
  92. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/drivers/sqlserver.py +0 -0
  93. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/errors/README.md +0 -0
  94. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/errors/__init__.py +0 -0
  95. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/errors/collector.py +0 -0
  96. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/errors/render.py +0 -0
  97. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/errors/types.py +0 -0
  98. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/__init__.py +0 -0
  99. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/collect.py +0 -0
  100. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/plan.py +0 -0
  101. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/presenter.py +0 -0
  102. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/feed/records.py +0 -0
  103. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/__init__.py +0 -0
  104. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/base.py +0 -0
  105. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/README.md +0 -0
  106. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/__init__.py +0 -0
  107. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/assets/README.md +0 -0
  108. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/assets/cytoscape.min.js +0 -0
  109. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/builder.py +0 -0
  110. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/generator.py +0 -0
  111. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/graphviz_render.py +0 -0
  112. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/html_render.py +0 -0
  113. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/layout.py +0 -0
  114. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/model.py +0 -0
  115. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/templates/er.html.jinja2 +0 -0
  116. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/diagram/themes.py +0 -0
  117. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/calculated.py +0 -0
  118. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/domain/__init__.py.jinja2 +0 -0
  119. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/facade.py.jinja2 +0 -0
  120. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/aggregation.jinja2 +0 -0
  121. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/dataframe.jinja2 +0 -0
  122. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/hierarchy.jinja2 +0 -0
  123. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/triggers.jinja2 +0 -0
  124. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/macros/vector.jinja2 +0 -0
  125. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/session.py.jinja2 +0 -0
  126. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/__init__.py.jinja2 +0 -0
  127. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/aggregation.py.jinja2 +0 -0
  128. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/context.py.jinja2 +0 -0
  129. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/utils/__init__.py.jinja2 +0 -0
  130. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/utils/encoders.py.jinja2 +0 -0
  131. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/utils/funcs.py.jinja2 +0 -0
  132. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/utils/hierarchy.py.jinja2 +0 -0
  133. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/utils/rls.py.jinja2 +0 -0
  134. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/templates/shared/vector.py.jinja2 +0 -0
  135. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/triggers/__init__.py +0 -0
  136. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/triggers/analyzer.py +0 -0
  137. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/triggers/renderer.py +0 -0
  138. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/triggers/steps.py +0 -0
  139. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/python_client/triggers/transformer.py +0 -0
  140. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/rules/__init__.py +0 -0
  141. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/generators/rules/templates/schema.md.jinja2 +0 -0
  142. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/README.md +0 -0
  143. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/__init__.py +0 -0
  144. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/blueprint.py +0 -0
  145. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/build.py +0 -0
  146. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/defaults.py +0 -0
  147. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/naming.py +0 -0
  148. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/reader.py +0 -0
  149. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/templates/schema.py.jinja2 +0 -0
  150. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/types.py +0 -0
  151. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/introspect/writer.py +0 -0
  152. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/README.md +0 -0
  153. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/__init__.py +0 -0
  154. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/README.md +0 -0
  155. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/__init__.py +0 -0
  156. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/calculated.py +0 -0
  157. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/collector.py +0 -0
  158. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/constraints.py +0 -0
  159. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/hints.py +0 -0
  160. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/relation_analyzer.py +0 -0
  161. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/analysis/schema_analyzer.py +0 -0
  162. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/README.md +0 -0
  163. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/__init__.py +0 -0
  164. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/annotations.py +0 -0
  165. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/base.py +0 -0
  166. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/calculated.py +0 -0
  167. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/constraints.py +0 -0
  168. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/datasource.py +0 -0
  169. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/descriptors.py +0 -0
  170. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/encoders.py +0 -0
  171. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/enumerations.py +0 -0
  172. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/relations.py +0 -0
  173. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/table.py +0 -0
  174. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/trigger_api.py +0 -0
  175. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/triggers.py +0 -0
  176. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/declarative/view.py +0 -0
  177. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/mapping/README.md +0 -0
  178. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/mapping/__init__.py +0 -0
  179. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/mapping/sqlalchemy_mapper.py +0 -0
  180. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/README.md +0 -0
  181. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/__init__.py +0 -0
  182. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/constraints.py +0 -0
  183. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/enumeration.py +0 -0
  184. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/relation.py +0 -0
  185. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/schema.py +0 -0
  186. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/model/trigger.py +0 -0
  187. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/naming.py +0 -0
  188. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/ordering.py +0 -0
  189. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/registry.py +0 -0
  190. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/orm/types.py +0 -0
  191. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/README.md +0 -0
  192. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/__init__.py +0 -0
  193. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/config.py +0 -0
  194. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/context.py +0 -0
  195. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/manager.py +0 -0
  196. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/project/paths.py +0 -0
  197. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/README.md +0 -0
  198. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/__init__.py +0 -0
  199. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/catalog.py +0 -0
  200. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/installer.py +0 -0
  201. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/meta.py +0 -0
  202. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/service.py +0 -0
  203. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/staleness.py +0 -0
  204. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/static/comandos.md +0 -0
  205. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/static/errores.md +0 -0
  206. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/rules/static/modelo.md +0 -0
  207. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/schema.py +0 -0
  208. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/README.md +0 -0
  209. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/__init__.py +0 -0
  210. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/diff/__init__.py +0 -0
  211. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/diff/constraints.py +0 -0
  212. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/diff/indexes.py +0 -0
  213. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/diff/tables.py +0 -0
  214. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/diff/views.py +0 -0
  215. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/executor.py +0 -0
  216. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/plan/__init__.py +0 -0
  217. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/plan/planner.py +0 -0
  218. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/plan/statements.py +0 -0
  219. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/presenter.py +0 -0
  220. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/report.py +0 -0
  221. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/safety.py +0 -0
  222. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/state/__init__.py +0 -0
  223. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/state/local.py +0 -0
  224. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/state/model.py +0 -0
  225. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/state/normalize.py +0 -0
  226. {tai_sql-0.7.2 → tai_sql-0.7.8}/tai_sql/sync/state/remote.py +0 -0
  227. {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.2
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.2"
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,3 @@
1
+ from .main import hash_migrate
2
+
3
+ __all__ = ['hash_migrate']
@@ -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(encrypt=True, description="Contraseña encriptada")
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
- """Qué columnas se actualizan si la fila ya existía: todas menos la clave."""
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
- Cuatro pasos, y **ninguno inventa una regla nueva**: los defaults salen del modelo, la
8
- clave de cifrado de `tai_sql/crypto.py` y la forma de un valor enumerado es la de siempre.
9
- Están juntos aquí porque los cuatro son lo mismo —una fila entra y sale—, y porque así el
10
- ejecutor no sabe nada de cifrado ni de enums: solo manda la fila a la base de datos.
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
- return _encrypt(table, row, cipher)
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 cuatro pasos
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 %}
@@ -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 %}