spinta 0.2.dev23__py3-none-any.whl → 0.2.dev25__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. spinta/backends/__init__.py +60 -2
  2. spinta/backends/components.py +8 -1
  3. spinta/backends/constants.py +10 -0
  4. spinta/backends/helpers.py +52 -20
  5. spinta/backends/postgresql/commands/wipe.py +1 -1
  6. spinta/backends/postgresql/components.py +7 -1
  7. spinta/backends/postgresql/helpers/migrate/actions.py +82 -3
  8. spinta/backends/postgresql/helpers/migrate/citus.py +383 -0
  9. spinta/cli/admin.py +2 -0
  10. spinta/cli/comment.py +113 -0
  11. spinta/cli/helpers/admin/components.py +3 -0
  12. spinta/cli/helpers/admin/registry.py +14 -0
  13. spinta/cli/helpers/admin/scripts/add_local_ids.py +80 -0
  14. spinta/cli/helpers/admin/scripts/citus_shard.py +126 -0
  15. spinta/cli/helpers/admin/scripts/remove_local_ids.py +55 -0
  16. spinta/cli/helpers/enums.py +5 -0
  17. spinta/cli/helpers/upgrade/scripts/backends/postgresql/comments.py +62 -26
  18. spinta/cli/inspect.py +3 -0
  19. spinta/cli/main.py +4 -0
  20. spinta/cli/manifest.py +11 -9
  21. spinta/cli/uncomment.py +110 -0
  22. spinta/components.py +17 -1
  23. spinta/config.py +4 -0
  24. spinta/config.yml +12 -1
  25. spinta/core/access.py +2 -0
  26. spinta/datasets/backends/dataframe/commands/read.py +5 -2
  27. spinta/datasets/backends/dataframe/ufuncs/query/ufuncs.py +11 -1
  28. spinta/datasets/backends/helpers.py +2 -1
  29. spinta/datasets/backends/sql/commands/cast.py +20 -17
  30. spinta/datasets/backends/sql/commands/read.py +50 -15
  31. spinta/datasets/backends/sql/ufuncs/query/ufuncs.py +11 -1
  32. spinta/datasets/components.py +0 -1
  33. spinta/datasets/helpers.py +36 -3
  34. spinta/dimensions/comments/components.py +3 -0
  35. spinta/dimensions/comments/helpers.py +2 -0
  36. spinta/dimensions/scope/__init__.py +0 -0
  37. spinta/dimensions/scope/components.py +64 -0
  38. spinta/dimensions/scope/helpers.py +56 -0
  39. spinta/dimensions/scope/ufuncs.py +51 -0
  40. spinta/exceptions.py +24 -5
  41. spinta/formats/html/commands.py +8 -5
  42. spinta/formats/html/helpers.py +7 -1
  43. spinta/manifests/commands/link.py +2 -0
  44. spinta/manifests/internal_sql/helpers.py +4 -2
  45. spinta/manifests/mermaid/helpers.py +251 -180
  46. spinta/manifests/sql/helpers.py +1 -1
  47. spinta/manifests/tabular/components.py +19 -0
  48. spinta/manifests/tabular/helpers.py +110 -6
  49. spinta/testing/citus.py +96 -0
  50. spinta/testing/cli.py +13 -0
  51. spinta/testing/pytest.py +35 -22
  52. spinta/types/__init__.py +1 -0
  53. spinta/types/array/__init__.py +1 -0
  54. spinta/types/array/link.py +6 -3
  55. spinta/types/backref/__init__.py +1 -0
  56. spinta/types/backref/link.py +9 -3
  57. spinta/types/config.py +13 -1
  58. spinta/types/datatype.py +11 -0
  59. spinta/types/helpers.py +67 -2
  60. spinta/types/model.py +91 -4
  61. spinta/types/ref/__init__.py +1 -0
  62. spinta/types/ref/link.py +5 -3
  63. spinta/urlparams.py +2 -0
  64. spinta/utils/enums.py +17 -8
  65. spinta/utils/naming.py +2 -2
  66. spinta/utils/url.py +3 -0
  67. {spinta-0.2.dev23.dist-info → spinta-0.2.dev25.dist-info}/METADATA +2 -1
  68. {spinta-0.2.dev23.dist-info → spinta-0.2.dev25.dist-info}/RECORD +71 -59
  69. {spinta-0.2.dev23.dist-info → spinta-0.2.dev25.dist-info}/WHEEL +0 -0
  70. {spinta-0.2.dev23.dist-info → spinta-0.2.dev25.dist-info}/entry_points.txt +0 -0
  71. {spinta-0.2.dev23.dist-info → spinta-0.2.dev25.dist-info}/licenses/LICENSE +0 -0
@@ -13,6 +13,7 @@ from typing import Iterable
13
13
  from typing import List
14
14
  from typing import Optional
15
15
 
16
+ from cbor2 import dumps as cbor_dumps
16
17
  import dateutil
17
18
  import shapely.geometry.base
18
19
  from geoalchemy2.elements import WKTElement, WKBElement
@@ -23,7 +24,7 @@ from spinta import commands
23
24
  from spinta import exceptions
24
25
  from spinta.backends.components import Backend
25
26
  from spinta.backends.components import SelectTree
26
- from spinta.backends.helpers import check_unknown_props, get_select_tree, prepare_response
27
+ from spinta.backends.helpers import check_unknown_props, get_select_tree, prepare_response, is_accessible_by_equals_sign
27
28
  from spinta.backends.helpers import flat_select_to_nested
28
29
  from spinta.backends.helpers import get_model_reserved_props
29
30
  from spinta.backends.helpers import get_select_prop_names
@@ -58,7 +59,19 @@ from spinta.exceptions import (
58
59
  from spinta.exceptions import NoItemRevision
59
60
  from spinta.formats.components import Format
60
61
  from spinta.manifests.components import Manifest
61
- from spinta.types.datatype import Array, ExternalRef, Inherit, PageType, BackRef, ArrayBackRef, Integer, Boolean, Denorm
62
+ from spinta.types.datatype import (
63
+ Array,
64
+ ExternalRef,
65
+ Inherit,
66
+ PageType,
67
+ BackRef,
68
+ ArrayBackRef,
69
+ Integer,
70
+ Boolean,
71
+ Denorm,
72
+ Base32,
73
+ String,
74
+ )
62
75
  from spinta.types.datatype import Binary
63
76
  from spinta.types.datatype import DataType
64
77
  from spinta.types.datatype import Date
@@ -590,12 +603,31 @@ def is_object_id(context: Context, value: str):
590
603
 
591
604
  @is_object_id.register(Context, Backend, Model, str)
592
605
  def is_object_id(context: Context, backend: Backend, model: Model, value: str):
606
+ return is_object_id(context, backend, model.properties["_id"].dtype, value)
607
+
608
+
609
+ @is_object_id.register(Context, Backend, PrimaryKey, str)
610
+ def is_object_id(context: Context, backend: Backend, dtype: PrimaryKey, value: str):
593
611
  try:
594
612
  return uuid.UUID(value).version == 4
595
613
  except ValueError:
596
614
  return False
597
615
 
598
616
 
617
+ @is_object_id.register(Context, Backend, DataType, str)
618
+ def is_object_id(context: Context, backend: Backend, dtype: DataType, value: str):
619
+ candidate = value
620
+ if is_accessible_by_equals_sign(dtype.prop, value):
621
+ if not value.startswith("="):
622
+ return False
623
+ candidate = value[1:]
624
+ try:
625
+ dtype.load(candidate)
626
+ except exceptions.InvalidValue:
627
+ return False
628
+ return True
629
+
630
+
599
631
  @is_object_id.register(Context, Backend, Model, uuid.UUID)
600
632
  def is_object_id(context: Context, backend: Backend, model: Model, value: uuid.UUID):
601
633
  return value.version == 4
@@ -1750,6 +1782,13 @@ def cast_backend_to_python(context: Context, dtype: DataType, backend: Backend,
1750
1782
  return data
1751
1783
 
1752
1784
 
1785
+ @commands.cast_backend_to_python.register(Context, String, Backend, object)
1786
+ def cast_backend_to_python(context: Context, dtype: String, backend: Backend, data: Any, **kwargs) -> Any:
1787
+ if data is None or is_nan(data):
1788
+ return None
1789
+ return str(data)
1790
+
1791
+
1753
1792
  @commands.cast_backend_to_python.register(Context, UUID, Backend, object)
1754
1793
  def cast_backend_to_python(context: Context, dtype: UUID, backend: Backend, data: Any, **kwargs) -> Any:
1755
1794
  if is_nan(data):
@@ -1891,6 +1930,13 @@ def cast_backend_to_python(context: Context, dtype: Ref, backend: Backend, data:
1891
1930
 
1892
1931
  processed_data = {}
1893
1932
  for key in data:
1933
+ if key == "_id":
1934
+ # _id reaches this dispatch already in its final form — produced by
1935
+ # handle_ref_key_assignment for external readers, or read directly
1936
+ # from the storage column for internal backends. Re-applying the
1937
+ # referenced model's _id cast double-encodes Base32 ids.
1938
+ processed_data[key] = data[key]
1939
+ continue
1894
1940
  prop = commands.resolve_property(dtype.prop.model, f"{dtype.prop.place}.{key}")
1895
1941
  if prop is not None:
1896
1942
  processed_data[key] = commands.cast_backend_to_python(context, prop, backend, data[key], **kwargs)
@@ -1939,6 +1985,18 @@ def cast_backend_to_python(context: Context, dtype: Denorm, backend: Backend, da
1939
1985
  return commands.cast_backend_to_python(context, dtype.rel_prop, backend, data, **kwargs)
1940
1986
 
1941
1987
 
1988
+ @commands.cast_backend_to_python.register(Context, Base32, Backend, object)
1989
+ def cast_backend_to_python(context: Context, dtype: Base32, backend: Backend, data: Any, **kwargs) -> Any:
1990
+ if is_nan(data):
1991
+ return None
1992
+ if isinstance(data, (list, tuple)):
1993
+ data = cbor_dumps(list(data))
1994
+ else:
1995
+ data = str(data).encode("utf-8")
1996
+ encoded = base64.b32encode(data)
1997
+ return encoded.rstrip(b"=").decode("utf-8")
1998
+
1999
+
1942
2000
  @commands.reload_backend_metadata.register(Context, Manifest, Backend)
1943
2001
  def reload_backend_metadata(context, manifest, backend):
1944
2002
  pass
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import contextlib
4
+ import dataclasses
4
5
  from typing import Any, Type
5
6
  from typing import Dict
6
7
  from typing import Optional
7
8
  from typing import Set
8
9
 
9
- from spinta.backends.constants import BackendOrigin, BackendFeatures
10
+ from spinta.backends.constants import BackendOrigin, BackendFeatures, DistributionType
10
11
  from spinta.core.ufuncs import Env
11
12
  from spinta.ufuncs.resultbuilder.components import ResultBuilder
12
13
 
@@ -58,3 +59,9 @@ class Backend:
58
59
 
59
60
 
60
61
  SelectTree = Optional[Dict[str, "SelectTree"]]
62
+
63
+
64
+ @dataclasses.dataclass
65
+ class DistributionStrategy:
66
+ distribution_type: DistributionType
67
+ property: str | None = None
@@ -35,3 +35,13 @@ class BackendFeatures(enum.Enum):
35
35
 
36
36
  # Backend supports
37
37
  EXPAND = "EXPAND"
38
+
39
+ # Backend supports sharding
40
+ DISTRIBUTE = "DISTRIBUTE"
41
+
42
+
43
+ class DistributionType(enum.Enum):
44
+ SCHEMA = "schema"
45
+ TABLE = "table"
46
+ COPY = "copy"
47
+ UNDISTRIBUTED = "undistributed"
@@ -26,7 +26,7 @@ from spinta.components import Model
26
26
  from spinta.components import Namespace
27
27
  from spinta.components import Property
28
28
  from spinta.exceptions import BackendUnavailable
29
- from spinta.types.datatype import DataType, Denorm
29
+ from spinta.types.datatype import DataType, Denorm, String, Base32, PrimaryKey
30
30
  from spinta.utils.data import take
31
31
  from spinta.backends.constants import TableType, BackendOrigin
32
32
 
@@ -344,7 +344,7 @@ def get_ns_reserved_props(action: Action) -> list[str]:
344
344
  return []
345
345
 
346
346
 
347
- @dataclasses.dataclass
347
+ @dataclasses.dataclass(frozen=True)
348
348
  class TableIdentifier:
349
349
  """
350
350
  Represents a table identifier across logical (app) and PostgreSQL layers.
@@ -391,35 +391,42 @@ class TableIdentifier:
391
391
  table_arg: str | None = dataclasses.field(default=None)
392
392
  default_pg_schema: str | None = dataclasses.field(default=None)
393
393
 
394
- logical_name: str = dataclasses.field(init=False)
394
+ logical_name: str = dataclasses.field(init=False, compare=False)
395
395
  # Name with namespace connected with '/', like it is used with Model class
396
- logical_qualified_name: str = dataclasses.field(init=False)
396
+ logical_qualified_name: str = dataclasses.field(init=False, compare=False)
397
397
 
398
- pg_table_name: str = dataclasses.field(init=False)
399
- pg_schema_name: str | None = dataclasses.field(init=False)
398
+ pg_table_name: str = dataclasses.field(init=False, compare=False)
399
+ pg_schema_name: str | None = dataclasses.field(init=False, compare=False)
400
400
  # Used for hashed schema and table names
401
- pg_qualified_name: str = dataclasses.field(init=False)
401
+ pg_qualified_name: str = dataclasses.field(init=False, compare=False)
402
402
  # Escaped qualified name, used for queries
403
- pg_escaped_qualified_name: str = dataclasses.field(init=False)
403
+ pg_escaped_qualified_name: str = dataclasses.field(init=False, compare=False)
404
404
 
405
405
  def __post_init__(self):
406
- self.logical_name = self.base_name + self.table_type.value
406
+ logical_name = self.base_name + self.table_type.value
407
407
  if self.table_arg:
408
- self.logical_name += "/" + self.table_arg
408
+ logical_name += "/" + self.table_arg
409
409
 
410
- self.logical_qualified_name = f"{self.schema}/{self.logical_name}" if self.schema else self.logical_name
410
+ logical_qualified_name = f"{self.schema}/{logical_name}" if self.schema else logical_name
411
411
 
412
- self.pg_table_name = get_pg_name(self.logical_name)
413
- self.pg_schema_name = get_pg_name(self.schema) if self.schema else self.default_pg_schema
414
- self.pg_qualified_name = (
415
- f"{self.pg_schema_name}.{self.pg_table_name}" if self.pg_schema_name else self.pg_table_name
416
- )
417
- self.pg_escaped_qualified_name = (
418
- f"{pg_identifier_preparer.quote(self.pg_schema_name)}.{pg_identifier_preparer.quote(self.pg_table_name)}"
419
- if self.pg_schema_name
420
- else pg_identifier_preparer.quote(self.pg_table_name)
412
+ pg_table_name = get_pg_name(logical_name)
413
+ pg_schema_name = get_pg_name(self.schema) if self.schema else self.default_pg_schema
414
+ pg_qualified_name = f"{pg_schema_name}.{pg_table_name}" if pg_schema_name else pg_table_name
415
+ pg_escaped_qualified_name = (
416
+ f"{pg_identifier_preparer.quote(pg_schema_name)}.{pg_identifier_preparer.quote(pg_table_name)}"
417
+ if pg_schema_name
418
+ else pg_identifier_preparer.quote(pg_table_name)
421
419
  )
422
420
 
421
+ # This is needed because we want to make this dataclass hashable (frozen=True, does that)
422
+ # But because it becomes immutable, we need to set all the attributes manually (the same way dataclass __init__ does).
423
+ object.__setattr__(self, "logical_name", logical_name)
424
+ object.__setattr__(self, "logical_qualified_name", logical_qualified_name)
425
+ object.__setattr__(self, "pg_table_name", pg_table_name)
426
+ object.__setattr__(self, "pg_schema_name", pg_schema_name)
427
+ object.__setattr__(self, "pg_qualified_name", pg_qualified_name)
428
+ object.__setattr__(self, "pg_escaped_qualified_name", pg_escaped_qualified_name)
429
+
423
430
  def change_table_type(self, new_type: TableType, table_arg: str | None = None) -> "TableIdentifier":
424
431
  return dataclasses.replace(self, table_type=new_type, table_arg=table_arg)
425
432
 
@@ -596,3 +603,28 @@ def extract_table_data_from_logical_name(table_name: str) -> tuple[str | None, T
596
603
  return data[0], table_type, None
597
604
 
598
605
  return None, None, None
606
+
607
+
608
+ def is_custom_id_prop(prop: Property) -> bool:
609
+ return prop.name == "_id" and not isinstance(prop.dtype, PrimaryKey)
610
+
611
+
612
+ def is_custom_revision_prop(prop: Property) -> bool:
613
+ return prop.name == "_revision" and prop.explicitly_given
614
+
615
+
616
+ def is_accessible_by_equals_sign(id_prop: Property, value: str | int) -> bool:
617
+ if isinstance(id_prop.dtype, Base32):
618
+ return True
619
+
620
+ if isinstance(id_prop.dtype, String):
621
+ return not check_if_model_primary_key_is_composite(id_prop.model)
622
+
623
+ return False
624
+
625
+
626
+ def check_if_model_primary_key_is_composite(model: Model) -> bool:
627
+ pkeys_count = len(model.external.pkeys)
628
+ if pkeys_count > 1:
629
+ return True
630
+ return False
@@ -48,7 +48,7 @@ def wipe(context: Context, model: Model, backend: PostgreSQL):
48
48
  if changelog_table_identifier.pg_schema_name
49
49
  else f'"{seqname}"'
50
50
  )
51
- connection.execute(f"ALTER SEQUENCE {seq_escaped_named} RESTART")
51
+ connection.execute(sa.func.setval(seq_escaped_named, 1, False))
52
52
 
53
53
  # Delete data table
54
54
  table = backend.get_table(model)
@@ -26,7 +26,13 @@ class PostgreSQL(Backend):
26
26
  },
27
27
  }
28
28
 
29
- features = {BackendFeatures.FILE_BLOCKS, BackendFeatures.WRITE, BackendFeatures.EXPAND, BackendFeatures.PAGINATION}
29
+ features = {
30
+ BackendFeatures.FILE_BLOCKS,
31
+ BackendFeatures.WRITE,
32
+ BackendFeatures.EXPAND,
33
+ BackendFeatures.PAGINATION,
34
+ BackendFeatures.DISTRIBUTE,
35
+ }
30
36
 
31
37
  engine: Engine = None
32
38
  schema: sa.MetaData = None
@@ -3,7 +3,7 @@ from sqlalchemy.dialects.postgresql import UUID
3
3
  from sqlalchemy.dialects import postgresql
4
4
 
5
5
  import sqlalchemy as sa
6
- from typing import TYPE_CHECKING
6
+ from typing import TYPE_CHECKING, Generator
7
7
 
8
8
  from spinta.backends.helpers import TableIdentifier
9
9
  from spinta.backends.postgresql.helpers.name import name_changed
@@ -91,6 +91,19 @@ class RenameTableMigrationAction(MigrationAction):
91
91
  )
92
92
 
93
93
 
94
+ class SetTableCommentMigrationAction(MigrationAction):
95
+ def __init__(self, table_identifier: TableIdentifier, comment: str) -> None:
96
+ self.table_identifier = table_identifier
97
+ self.comment = comment
98
+
99
+ def execute(self, op: "Operations") -> None:
100
+ op.create_table_comment(
101
+ table_name=self.table_identifier.pg_table_name,
102
+ comment=self.comment,
103
+ schema=self.table_identifier.pg_schema_name,
104
+ )
105
+
106
+
94
107
  class AddColumnMigrationAction(MigrationAction):
95
108
  def __init__(self, table_identifier: TableIdentifier, column: sa.Column) -> None:
96
109
  self.table_identifier = table_identifier
@@ -149,6 +162,21 @@ class AlterColumnMigrationAction(MigrationAction):
149
162
  )
150
163
 
151
164
 
165
+ class SetColumnCommentMigrationAction(MigrationAction):
166
+ def __init__(self, table_identifier: TableIdentifier, column: str, comment: str) -> None:
167
+ self.table_identifier = table_identifier
168
+ self.comment = comment
169
+ self.column = column
170
+
171
+ def execute(self, op: "Operations") -> None:
172
+ op.alter_column(
173
+ table_name=self.table_identifier.pg_table_name,
174
+ column_name=self.column,
175
+ comment=self.comment,
176
+ schema=self.table_identifier.pg_schema_name,
177
+ )
178
+
179
+
152
180
  class DropConstraintMigrationAction(MigrationAction):
153
181
  def __init__(self, table_identifier: TableIdentifier, constraint_name: str) -> None:
154
182
  self.table_identifier = table_identifier
@@ -547,6 +575,50 @@ class CreateSchemaMigrationAction(MigrationAction):
547
575
  op.execute(self.query)
548
576
 
549
577
 
578
+ class DistributeSchema(MigrationAction):
579
+ def __init__(self, schema_name: str) -> None:
580
+ self.schema_name = schema_name
581
+ self.query = f"SELECT citus_schema_distribute('{pg_identifier_preparer.quote(schema_name)}')"
582
+
583
+ def execute(self, op: "Operations") -> None:
584
+ op.execute(self.query)
585
+
586
+
587
+ class DistributeReference(MigrationAction):
588
+ def __init__(self, table_identifier: TableIdentifier) -> None:
589
+ self.query = f"SELECT create_reference_table('{table_identifier.pg_escaped_qualified_name}')"
590
+
591
+ def execute(self, op: "Operations") -> None:
592
+ op.execute(self.query)
593
+
594
+
595
+ class DistributeTable(MigrationAction):
596
+ def __init__(self, table_identifier: TableIdentifier, column: str) -> None:
597
+ self.query = f"SELECT create_distributed_table('{table_identifier.pg_escaped_qualified_name}', '{column}')"
598
+
599
+ def execute(self, op: "Operations") -> None:
600
+ op.execute(self.query)
601
+
602
+
603
+ class UndistributeSchema(MigrationAction):
604
+ def __init__(self, schema_name: str) -> None:
605
+ self.schema_name = schema_name
606
+ self.query = f"SELECT citus_schema_undistribute('{pg_identifier_preparer.quote(schema_name)}')"
607
+
608
+ def execute(self, op: "Operations") -> None:
609
+ op.execute(self.query)
610
+
611
+
612
+ class UndistributeTable(MigrationAction):
613
+ def __init__(self, table_identifier: TableIdentifier) -> None:
614
+ self.query = (
615
+ f"SELECT undistribute_table('{table_identifier.pg_escaped_qualified_name}', cascade_via_foreign_keys=>true)"
616
+ )
617
+
618
+ def execute(self, op: "Operations") -> None:
619
+ op.execute(self.query)
620
+
621
+
550
622
  class MigrationHandler:
551
623
  def __init__(self) -> None:
552
624
  self.migrations: list[MigrationAction] = []
@@ -571,8 +643,15 @@ class MigrationHandler:
571
643
  return True
572
644
  return False
573
645
 
574
- def run_migrations(self, op: "Operations") -> None:
646
+ def gather_migrations(self) -> Generator[MigrationAction, None, None]:
575
647
  for migration in self.migrations:
576
- migration.execute(op)
648
+ yield migration
577
649
  for migration in self.foreign_key_migration:
650
+ yield migration
651
+
652
+ def run_migrations(self, op: "Operations") -> None:
653
+ for migration in self.gather_migrations():
578
654
  migration.execute(op)
655
+
656
+ def count(self) -> int:
657
+ return len(list(self.gather_migrations()))