diff --git a/.github/workflows/handle-required-checks.yml b/.github/workflows/handle-required-checks.yml index 2b68f8b1fa..40677fd4bc 100644 --- a/.github/workflows/handle-required-checks.yml +++ b/.github/workflows/handle-required-checks.yml @@ -5,11 +5,11 @@ name: handle-required-checks on: push: paths-ignore: - - mathesar_ui/* + - 'mathesar_ui/**' - '**.py' pull_request: paths-ignore: - - mathesar_ui/* + - 'mathesar_ui/**' - '**.py' jobs: lint: diff --git a/.github/workflows/run-lint-audit-tests-ui.yml b/.github/workflows/run-lint-audit-tests-ui.yml index 3b42aa383c..1b0d20841d 100644 --- a/.github/workflows/run-lint-audit-tests-ui.yml +++ b/.github/workflows/run-lint-audit-tests-ui.yml @@ -3,17 +3,14 @@ name: UI - Lint, Audit and Tests on: push: paths: - - mathesar_ui/* + - 'mathesar_ui/**' pull_request: paths: - - mathesar_ui/* + - 'mathesar_ui/**' jobs: format: runs-on: ubuntu-latest - # We only want to run on external PRs, since internal PRs are covered by "push" - # This prevents this from running twice on internal PRs - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository defaults: run: working-directory: ./mathesar_ui @@ -36,9 +33,6 @@ jobs: lint: runs-on: ubuntu-latest - # We only want to run on external PRs, since internal PRs are covered by "push" - # This prevents this from running twice on internal PRs - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository defaults: run: working-directory: ./mathesar_ui @@ -61,9 +55,6 @@ jobs: typecheck: runs-on: ubuntu-latest - # We only want to run on external PRs, since internal PRs are covered by "push" - # This prevents this from running twice on internal PRs - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository defaults: run: working-directory: ./mathesar_ui @@ -86,9 +77,6 @@ jobs: audit: runs-on: ubuntu-latest - # We only want to run on external PRs, since internal PRs are covered by "push" - # This prevents this from running twice on internal PRs - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository defaults: run: working-directory: ./mathesar_ui @@ -120,9 +108,6 @@ jobs: tests: runs-on: ubuntu-latest - # We only want to run on external PRs, since internal PRs are covered by "push" - # This prevents this from running twice on internal PRs - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository defaults: run: working-directory: ./mathesar_ui diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 8083398fad..f0bac9dd2c 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -3,10 +3,10 @@ name: test-docs on: push: paths: - - docs/* + - 'docs/**' pull_request: paths: - - docs/* + - 'docs/**' jobs: deploy: diff --git a/README.md b/README.md index 594089fca1..e60b534f1c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- WebsiteDocsLive DemoMatrix (chat)Wiki + WebsiteDocsLive DemoMatrix (chat)DiscordWiki

diff --git a/config/settings/common_settings.py b/config/settings/common_settings.py index d33b2d500f..1c738d0a72 100644 --- a/config/settings/common_settings.py +++ b/config/settings/common_settings.py @@ -37,6 +37,7 @@ def pipe_delim(pipe_string): "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", + "whitenoise.runserver_nostatic", "django.contrib.staticfiles", "rest_framework", "django_filters", @@ -46,6 +47,7 @@ def pipe_delim(pipe_string): MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -221,6 +223,7 @@ def pipe_delim(pipe_string): # https://vitejs.dev/guide/assets.html # https://vitejs.dev/guide/backend-integration.html STATICFILES_DIRS = [MATHESAR_UI_SOURCE_LOCATION, MATHESAR_STATIC_NON_CODE_FILES_LOCATION] if MATHESAR_MODE == 'DEVELOPMENT' else [MATHESAR_UI_BUILD_LOCATION, MATHESAR_STATIC_NON_CODE_FILES_LOCATION] +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" # Accounts AUTH_USER_MODEL = 'mathesar.User' diff --git a/db/columns/base.py b/db/columns/base.py index d03b1eb0f3..4927ba0408 100644 --- a/db/columns/base.py +++ b/db/columns/base.py @@ -138,6 +138,10 @@ def table_oid(self): @property def is_default(self): default_def = DEFAULT_COLUMNS.get(self.name, False) + try: + self.type.python_type + except NotImplementedError: + return False return ( default_def and self.type.python_type == default_def[TYPE]().python_type diff --git a/db/columns/operations/infer_types.py b/db/columns/operations/infer_types.py index ea2fd4d0cb..e3acf15bec 100644 --- a/db/columns/operations/infer_types.py +++ b/db/columns/operations/infer_types.py @@ -5,6 +5,7 @@ from db.columns.exceptions import DagCycleError from db.columns.operations.alter import alter_column_type +from db.columns.operations.select import determine_whether_column_contains_data from db.tables.operations.select import get_oid_from_table, reflect_table from db.types.base import PostgresType, MathesarCustomType, get_available_known_db_types from db.metadata import get_empty_metadata @@ -18,9 +19,7 @@ PostgresType.BOOLEAN: [], MathesarCustomType.EMAIL: [], PostgresType.INTERVAL: [], - PostgresType.NUMERIC: [ - PostgresType.BOOLEAN, - ], + PostgresType.NUMERIC: [], PostgresType.TEXT: [ PostgresType.BOOLEAN, PostgresType.DATE, @@ -28,9 +27,10 @@ MathesarCustomType.MATHESAR_MONEY, PostgresType.TIMESTAMP_WITHOUT_TIME_ZONE, PostgresType.TIMESTAMP_WITH_TIME_ZONE, - # We only infer to TIME_WITHOUT_TIME_ZONE as time zones don't make much sense - # without additional date information. See postgres documentation for further - # details: https://www.postgresql.org/docs/13/datatype-datetime.html + # We only infer to TIME_WITHOUT_TIME_ZONE as time zones don't make much + # sense without additional date information. See postgres documentation + # for further details: + # https://www.postgresql.org/docs/13/datatype-datetime.html PostgresType.TIME_WITHOUT_TIME_ZONE, PostgresType.INTERVAL, MathesarCustomType.EMAIL, @@ -41,26 +41,45 @@ } -def infer_column_type(schema, table_name, column_name, engine, depth=0, type_inference_dag=None, metadata=None, columns_might_have_defaults=True): +def infer_column_type( + schema, + table_name, + column_name, + engine, + depth=0, + type_inference_dag=None, + metadata=None, + columns_might_have_defaults=True, +): """ - Attempts to cast the column to the best type for it, given the mappings defined in TYPE_INFERENCE_DAG - and _get_type_classes_mapped_to_dag_nodes. Returns the resulting column type's class. + Attempt to cast the column to the best type for it. + + Returns the resulting column type's class. Algorithm: - 1. reflect the column's type class; - 2. use _get_type_classes_mapped_to_dag_nodes to map it to a TYPE_INFERENCE_DAG key; - 3. look up the sequence of types referred to by that key on the TYPE_INFERENCE_DAG; - - if there's no such key on the TYPE_INFERENCE_DAG dict, or if its value is an empty - list, return the current column type's class; - 4. iterate through that sequence of types trying to alter the column's type to them; - - if the column's type is altered successfully, break iteration and return the output - of running infer_column_type again (trigger tail recursion); - - if none of the column type alterations succeed, return the current column type's - class. + 1. Check for any data in the column. + - If the column is empty, return the column's current type + class. + 2. reflect the column's type class. + 3. Use _get_type_classes_mapped_to_dag_nodes to map it to a + TYPE_INFERENCE_DAG key. + 4. Look up the sequence of types referred to by that key on the + TYPE_INFERENCE_DAG. + - If there's no such key on the TYPE_INFERENCE_DAG dict, or if + its value is an empty list, return the current column's type + class. + 5. Iterate through that sequence of types trying to alter the + column's type to them. + - If the column's type is altered successfully, break + iteration and return the output of running infer_column_type + again (trigger tail recursion). + - If none of the column type alterations succeed, return the + current column's type class. """ + metadata = metadata if metadata else get_empty_metadata() + if type_inference_dag is None: type_inference_dag = TYPE_INFERENCE_DAG - metadata = metadata if metadata else get_empty_metadata() if depth > MAX_INFERENCE_DAG_DEPTH: raise DagCycleError("The type_inference_dag likely has a cycle") type_classes_to_dag_nodes = _get_type_classes_mapped_to_dag_nodes(engine) @@ -71,11 +90,18 @@ def infer_column_type(schema, table_name, column_name, engine, depth=0, type_inf column_name=column_name, metadata=metadata, ) + table_oid = get_oid_from_table(table_name, schema, engine) + column_contains_data = determine_whether_column_contains_data( + table_oid, column_name, engine, metadata + ) + # We short-circuit in this case since we can't infer type without data. + if not column_contains_data: + return column_type_class + # a DAG node will be a DatabaseType Enum dag_node = type_classes_to_dag_nodes.get(column_type_class) logger.debug(f"dag_node: {dag_node}") types_to_cast_to = type_inference_dag.get(dag_node, []) - table_oid = get_oid_from_table(table_name, schema, engine) for db_type in types_to_cast_to: try: with engine.begin() as conn: diff --git a/db/columns/operations/select.py b/db/columns/operations/select.py index f18663aa5e..b5823a856c 100644 --- a/db/columns/operations/select.py +++ b/db/columns/operations/select.py @@ -1,7 +1,7 @@ import warnings from pglast import Node, parse_sql -from sqlalchemy import and_, asc, cast, select, text +from sqlalchemy import and_, asc, cast, select, text, exists from db.columns.exceptions import DynamicDefaultWarning from db.tables.operations.select import reflect_table_from_oid @@ -151,6 +151,20 @@ def get_column_default_dict(table_oid, attnum, engine, metadata, connection_to_u return {"value": default_value, "is_dynamic": is_dynamic} +def determine_whether_column_contains_data( + table_oid, column_name, engine, metadata, connection_to_use=None +): + """ + Given a column, return True if it contains data, False otherwise. + """ + sa_table = reflect_table_from_oid( + table_oid, engine, metadata=metadata, connection_to_use=connection_to_use, + ) + sel = select(exists(1).where(sa_table.columns[column_name] != None)) # noqa + contains_data = execute_statement(engine, sel, connection_to_use).scalar() + return contains_data + + def get_column_from_oid_and_attnum(table_oid, attnum, engine, metadata, connection_to_use=None): sa_table = reflect_table_from_oid(table_oid, engine, metadata=metadata, connection_to_use=connection_to_use) column_name = get_column_name_from_attnum(table_oid, attnum, engine, metadata=metadata, connection_to_use=connection_to_use) diff --git a/db/identifiers.py b/db/identifiers.py new file mode 100644 index 0000000000..7d9a175fdd --- /dev/null +++ b/db/identifiers.py @@ -0,0 +1,64 @@ +import hashlib + + +def truncate_if_necessary(identifier): + """ + Takes an identifier and returns it, truncating it, if it is too long. The truncated version + will end with a hash of the passed identifier, therefore column name collision should be very + rare. + + Iteratively removes characters from the end of the identifier, until the resulting string, with + the suffix hash of the identifier appended, is short enough that it doesn't need to be truncated + anymore. Whitespace is trimmed from the truncated identifier before appending the suffix. + """ + assert type(identifier) is str + if not is_identifier_too_long(identifier): + return identifier + right_side = "-" + _get_truncation_hash(identifier) + identifier_length = len(identifier) + assert len(right_side) < identifier_length # Sanity check + range_of_num_of_chars_to_remove = range(1, identifier_length) + for num_of_chars_to_remove in range_of_num_of_chars_to_remove: + left_side = identifier[:num_of_chars_to_remove * -1] + left_side = left_side.rstrip() + truncated_identifier = left_side + right_side + if not is_identifier_too_long(truncated_identifier): + return truncated_identifier + raise Exception( + "Acceptable truncation not found; should never happen." + ) + + +def is_identifier_too_long(identifier): + postgres_identifier_size_limit = 63 + size = _get_size_of_identifier_in_bytes(identifier) + return size > postgres_identifier_size_limit + + +def _get_truncation_hash(identifier): + """ + Produces an 8-character string hash of the passed identifier. + + Using hash function blake2s, because it seems fairly recommended and it seems to be better + suited for shorter digests than blake2b. We want short digests to not take up too much of the + truncated identifier in whose construction this will be used. + """ + h = hashlib.blake2s(digest_size=4) + bytes = _get_identifier_in_bytes(identifier) + h.update(bytes) + return h.hexdigest() + + +def _get_size_of_identifier_in_bytes(s): + bytes = _get_identifier_in_bytes(s) + return len(bytes) + + +def _get_identifier_in_bytes(s): + """ + Afaict, following Postgres doc [0] says that UTF-8 supports all languages; therefore, different + server locale configurations should not break this. + + [0] https://www.postgresql.org/docs/13/multibyte.html + """ + return s.encode('utf-8') diff --git a/db/records/operations/delete.py b/db/records/operations/delete.py index ab3e8a4ad1..02454da56e 100644 --- a/db/records/operations/delete.py +++ b/db/records/operations/delete.py @@ -8,3 +8,10 @@ def delete_record(table, engine, id_value): query = delete(table).where(primary_key_column == id_value) with engine.begin() as conn: return conn.execute(query) + + +def bulk_delete_records(table, engine, id_values): + primary_key_column = get_primary_key_column(table) + query = delete(table).where(primary_key_column.in_(id_values)) + with engine.begin() as conn: + return conn.execute(query) diff --git a/db/records/operations/sort.py b/db/records/operations/sort.py index 667cf78cf0..9e51eaa61b 100644 --- a/db/records/operations/sort.py +++ b/db/records/operations/sort.py @@ -44,27 +44,37 @@ def _build_order_by_all_columns_clause(relation): To be used when we have failed to find any other ordering criteria, since ordering by all columns is inherently inefficient. - Note the filtering out of internal columns. Before applying this fix, psycopg was throwing an error - like "could not identify an ordering operator for type json", because we were trying to - sort by an internal column like `__mathesar_group_metadata`, which has type `json`, which - requires special handling to be sorted. The problem is bypassed by not attempting to sort on - internal columns. + Note the filtering out some columns, namely internal columns and non-orderable columns. See + their docstrings for details. """ return [ {'field': col, 'direction': 'asc'} for col in relation.columns - if not _is_internal_column(col) + if _is_col_orderable(col) and not _is_internal_column(col) ] def _is_internal_column(col): """ + Columns that Mathesar adds for its own devices and does not expose to the user. We don't want + to sort by these. + Might not be exhaustive, take care. """ return col.name == '__mathesar_group_metadata' +def _is_col_orderable(col): + """ + Some columns are not orderable (or at least don't have a non-ambiguous way to define order + without additional logic). We only want to order by orderably columns. + """ + data_type = col.type + non_orderable_type = ['Binary', 'LargeBinary', 'PickleType', 'ARRAY', 'JSON', 'JSONB'] + return str(data_type) not in non_orderable_type + + def apply_relation_sorting(relation, sort_spec): order_by_list = [ _get_sorted_column_obj_from_spec(relation, spec) for spec in sort_spec diff --git a/db/tests/columns/test_base.py b/db/tests/columns/test_base.py index 05c2b2c01f..3326da5ebf 100644 --- a/db/tests/columns/test_base.py +++ b/db/tests/columns/test_base.py @@ -4,6 +4,7 @@ from sqlalchemy import ( INTEGER, ForeignKey, VARCHAR, CHAR, NUMERIC, ARRAY, JSON ) +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.sql.sqltypes import NullType from db.columns.base import MathesarColumn @@ -114,6 +115,11 @@ def test_MC_is_default_when_true(): assert default_col.is_default +def test_MC_is_default_with_uuid_col(): + col = MathesarColumn('id', UUID, primary_key=True, nullable=False) + assert not col.is_default + + def test_MC_is_default_when_false_for_name(): for default_col in DEFAULT_COLUMNS: dc_definition = DEFAULT_COLUMNS[default_col] diff --git a/db/tests/conftest.py b/db/tests/conftest.py index 6cd63cab48..5750f730d8 100644 --- a/db/tests/conftest.py +++ b/db/tests/conftest.py @@ -21,6 +21,7 @@ MAGNITUDE_SQL = os.path.join(RESOURCES, "magnitude_testing_create.sql") ARRAY_SQL = os.path.join(RESOURCES, "array_create.sql") JSON_SQL = os.path.join(RESOURCES, "json_sort.sql") +JSON_WITHOUT_PKEY_SQL = os.path.join(RESOURCES, "json_without_pkey.sql") BOOKS_FROM_SQL = os.path.join(RESOURCES, "books_import_from.sql") BOOKS_TARGET_SQL = os.path.join(RESOURCES, "books_import_target.sql") @@ -34,6 +35,15 @@ def engine_with_roster(engine_with_schema): yield engine, schema +@pytest.fixture +def engine_with_JSON_without_pkey(engine_with_schema): + engine, schema = engine_with_schema + with engine.begin() as conn, open(JSON_WITHOUT_PKEY_SQL) as f: + conn.execute(text(f"SET search_path={schema}")) + conn.execute(text(f.read())) + yield engine, schema + + @pytest.fixture def engine_with_uris(engine_with_schema): engine, schema = engine_with_schema @@ -120,6 +130,11 @@ def roster_table_name(): return "Roster" +@pytest.fixture(scope='session') +def json_without_pkey_name(): + return "json_without_pkey" + + @pytest.fixture(scope='session') def uris_table_name(): return "uris" @@ -212,6 +227,14 @@ def roster_table_obj(engine_with_roster, roster_table_name): return table, engine +@pytest.fixture +def json_without_pkey_table_obj(engine_with_JSON_without_pkey, json_without_pkey_name): + engine, schema = engine_with_JSON_without_pkey + metadata = MetaData(bind=engine) + table = Table(json_without_pkey_name, metadata, schema=schema, autoload_with=engine) + return table, engine + + @pytest.fixture def magnitude_table_obj(engine_with_magnitude, magnitude_table_name): engine, schema = engine_with_magnitude diff --git a/db/tests/resources/json_without_pkey.sql b/db/tests/resources/json_without_pkey.sql new file mode 100644 index 0000000000..6ed549ba91 --- /dev/null +++ b/db/tests/resources/json_without_pkey.sql @@ -0,0 +1,7 @@ +CREATE TABLE "json_without_pkey" ( + "json_object" json +); + +INSERT INTO "json_without_pkey" VALUES +('{"name": "John", "age": 30}'::json), +('{"name": "Earl James", "age": 30}'::json); diff --git a/db/tests/tables/operations/test_infer_types.py b/db/tests/tables/operations/test_infer_types.py index a98f128c1e..b531c57b58 100644 --- a/db/tests/tables/operations/test_infer_types.py +++ b/db/tests/tables/operations/test_infer_types.py @@ -28,7 +28,33 @@ "-7,53,00,00,000.0", "-140'004'453.0" ], - PostgresType.NUMERIC), + PostgresType.NUMERIC + ), + ( + PostgresType.TEXT, + [], + PostgresType.TEXT, + ), + ( + PostgresType.TEXT, + ["1.0"], + PostgresType.NUMERIC + ), + ( + PostgresType.TEXT, + ["1"], + PostgresType.BOOLEAN + ), + ( + PostgresType.TEXT, + ["0"], + PostgresType.BOOLEAN + ), + ( + PostgresType.TEXT, + ["1", "0"], + PostgresType.BOOLEAN + ), ( PostgresType.NUMERIC, [0, 2, 1, 0], @@ -37,7 +63,7 @@ ( PostgresType.NUMERIC, [0, 1, 1, 0], - PostgresType.BOOLEAN + PostgresType.NUMERIC, ), ( PostgresType.TEXT, diff --git a/db/tests/transforms/test_json_without_pkey.py b/db/tests/transforms/test_json_without_pkey.py new file mode 100644 index 0000000000..2ce417e8e4 --- /dev/null +++ b/db/tests/transforms/test_json_without_pkey.py @@ -0,0 +1,7 @@ +from db.records.operations.select import get_records + + +def test_default_ordering(json_without_pkey_table_obj): + table, engine = json_without_pkey_table_obj + records = get_records(table, engine, fallback_to_default_ordering=True) + assert len(records) == 2 diff --git a/demo/settings.py b/demo/settings.py index 41d065b6c2..94117c4084 100644 --- a/demo/settings.py +++ b/demo/settings.py @@ -20,3 +20,4 @@ default='/var/lib/mathesar/demo/arxiv_db_schema_log' ) BASE_TEMPLATE_ADDITIONAL_SCRIPT_TEMPLATES += ['demo/analytics.html'] # noqa +ROOT_URLCONF = "demo.urls" diff --git a/demo/urls.py b/demo/urls.py new file mode 100644 index 0000000000..91c5188feb --- /dev/null +++ b/demo/urls.py @@ -0,0 +1,21 @@ +from django.contrib.auth.decorators import login_required +from django.urls import include, path, re_path +from django.views.generic import RedirectView +from rest_framework.decorators import api_view +from rest_framework.exceptions import PermissionDenied + +from config import urls as root_urls + + +@login_required +@api_view(['POST']) +def permission_denied(_, *args, **kwargs): + raise PermissionDenied() + + +urlpatterns = [ + re_path(r'^api/ui/v0/users/(?P[^/.]+)/password_reset/', permission_denied, name='password_reset'), + path('api/ui/v0/users/password_change/', permission_denied, name='password_change'), + path('auth/password_reset_confirm/', RedirectView.as_view(url='/'), name='password_reset'), + path('', include(root_urls)), +] diff --git a/docs/docs/install/docker-compose/index.md b/docs/docs/install/docker-compose/index.md index a5b43a35fa..bc1579146f 100644 --- a/docs/docs/install/docker-compose/index.md +++ b/docs/docs/install/docker-compose/index.md @@ -31,7 +31,7 @@ 1. Paste this command into your terminal to begin installing the latest version of Mathesar: ```sh - bash <(curl -sfSL https://raw.githubusercontent.com/centerofci/mathesar/0.1.0/install.sh) + bash <(curl -sfSL https://raw.githubusercontent.com/centerofci/mathesar/0.1.1/install.sh) ``` 1. Follow the interactive prompts to configure your Mathesar installation. diff --git a/docs/docs/product/users.md b/docs/docs/product/users.md index 875024b2db..7cef8d20f5 100644 --- a/docs/docs/product/users.md +++ b/docs/docs/product/users.md @@ -125,9 +125,6 @@ There are three levels of schema roles: ## Order of Precedence -!!! warning - - The Mathesar UI currently has an issue where **schema roles** _always_ take precedence over **database roles**. This behavior will is not in line with the API and will be fixed in a future release. - If a user has both a **Database Role** and a **Schema Role** for a schema within the same database, the **Schema Role** will only have an effect if it grants more permissions. Examples: diff --git a/install.sh b/install.sh index 3f2b7a0a03..aba0361fcd 100755 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -e clear -x -github_tag=${1-"0.1.0"} +github_tag=${1-"0.1.1"} min_maj_docker_version=20 min_maj_docker_compose_version=2 min_min_docker_compose_version=7 diff --git a/mathesar/__init__.py b/mathesar/__init__.py index e712642336..b0306b2d46 100644 --- a/mathesar/__init__.py +++ b/mathesar/__init__.py @@ -1,3 +1,3 @@ default_app_config = 'mathesar.apps.MathesarConfig' -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/mathesar/api/db/permissions/records.py b/mathesar/api/db/permissions/records.py index 9abf292304..b7164a3e4b 100644 --- a/mathesar/api/db/permissions/records.py +++ b/mathesar/api/db/permissions/records.py @@ -21,7 +21,7 @@ class RecordAccessPolicy(AccessPolicy): 'condition_expression': ['(is_superuser or is_table_viewer)'] }, { - 'action': ['destroy', 'update', 'partial_update', 'create'], + 'action': ['destroy', 'update', 'partial_update', 'create', 'delete'], 'principal': 'authenticated', 'effect': 'allow', 'condition_expression': ['(is_superuser or is_table_editor)'] diff --git a/mathesar/api/exceptions/database_exceptions/exceptions.py b/mathesar/api/exceptions/database_exceptions/exceptions.py index 3b77f8da6d..df355d1ee5 100644 --- a/mathesar/api/exceptions/database_exceptions/exceptions.py +++ b/mathesar/api/exceptions/database_exceptions/exceptions.py @@ -425,3 +425,19 @@ def __init__( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ): super().__init__(exception, self.error_code, message, field, details, status_code) + + +class IdentifierTooLong(MathesarAPIException): + error_code = ErrorCodes.IdentifierTooLong.value + + def __init__( + self, + exception=None, + message="Identifier is longer than Postgres' limit of 63 bytes.", + field=None, + details=None, + status_code=status.HTTP_400_BAD_REQUEST + ): + if exception is None: + exception = Exception(message) + super().__init__(exception, self.error_code, message, field, details, status_code) diff --git a/mathesar/api/exceptions/error_codes.py b/mathesar/api/exceptions/error_codes.py index b6fe270dfc..f85022890d 100644 --- a/mathesar/api/exceptions/error_codes.py +++ b/mathesar/api/exceptions/error_codes.py @@ -61,3 +61,4 @@ class ErrorCodes(Enum): IncorrectOldPassword = 4419 EditingPublicSchema = 4421 DuplicateUIQueryInSchema = 4422 + IdentifierTooLong = 4423 diff --git a/mathesar/api/serializers/columns.py b/mathesar/api/serializers/columns.py index 523d495319..939e90b729 100644 --- a/mathesar/api/serializers/columns.py +++ b/mathesar/api/serializers/columns.py @@ -3,6 +3,7 @@ from rest_framework.fields import empty, SerializerMethodField from rest_framework.settings import api_settings +from db.identifiers import is_identifier_too_long from db.columns.exceptions import InvalidTypeError from db.columns.exceptions import InvalidTypeOptionError from db.types.base import PostgresType, MathesarCustomType @@ -110,6 +111,11 @@ def to_internal_value(self, data): self.context[DISPLAY_OPTIONS_SERIALIZER_MAPPING_KEY] = db_type return super().to_internal_value(data) + def validate_name(self, name): + if is_identifier_too_long(name): + raise database_api_exceptions.IdentifierTooLong(field='name') + return name + def _force_canonical_type(representation, db_type): """ diff --git a/mathesar/api/serializers/constraints.py b/mathesar/api/serializers/constraints.py index f457b9c3f6..bd6a58266e 100644 --- a/mathesar/api/serializers/constraints.py +++ b/mathesar/api/serializers/constraints.py @@ -1,7 +1,10 @@ from psycopg2.errors import DuplicateTable, UniqueViolation from rest_framework import serializers, status from sqlalchemy.exc import IntegrityError, ProgrammingError + from db.constraints import utils as constraint_utils +from db.identifiers import is_identifier_too_long +from db.constraints.base import ForeignKeyConstraint, UniqueConstraint import mathesar.api.exceptions.database_exceptions.exceptions as database_api_exceptions import mathesar.api.exceptions.generic_exceptions.base_exceptions as base_api_exceptions @@ -9,7 +12,6 @@ ConstraintColumnEmptyAPIException, UnsupportedConstraintAPIException, InvalidTableName ) -from db.constraints.base import ForeignKeyConstraint, UniqueConstraint from mathesar.api.serializers.shared_serializers import ( MathesarPolymorphicErrorMixin, ReadWritePolymorphicSerializerMappingMixin, @@ -70,6 +72,11 @@ def create(self, validated_data): raise base_api_exceptions.MathesarAPIException(e) return constraint + def validate_name(self, name): + if is_identifier_too_long(name): + raise database_api_exceptions.IdentifierTooLong(field='name') + return name + class ForeignKeyConstraintSerializer(BaseConstraintSerializer): class Meta: diff --git a/mathesar/api/serializers/schemas.py b/mathesar/api/serializers/schemas.py index 4773305a96..10c5de682a 100644 --- a/mathesar/api/serializers/schemas.py +++ b/mathesar/api/serializers/schemas.py @@ -1,11 +1,15 @@ from rest_access_policy import PermittedSlugRelatedField from rest_framework import serializers -from mathesar.api.db.permissions.table import TableAccessPolicy +from db.identifiers import is_identifier_too_long +from mathesar.api.db.permissions.table import TableAccessPolicy from mathesar.api.db.permissions.database import DatabaseAccessPolicy from mathesar.api.exceptions.mixins import MathesarErrorMessageMixin from mathesar.models.base import Database, Schema, Table +from mathesar.api.exceptions.database_exceptions import ( + exceptions as database_api_exceptions +) class SchemaSerializer(MathesarErrorMessageMixin, serializers.HyperlinkedModelSerializer): @@ -38,3 +42,8 @@ def get_num_tables(self, obj): def get_num_queries(self, obj): return sum(t.queries.count() for t in obj.tables.all()) + + def validate_name(self, name): + if is_identifier_too_long(name): + raise database_api_exceptions.IdentifierTooLong(field='name') + return name diff --git a/mathesar/api/serializers/tables.py b/mathesar/api/serializers/tables.py index 859cd826d2..5e23d4825d 100644 --- a/mathesar/api/serializers/tables.py +++ b/mathesar/api/serializers/tables.py @@ -9,6 +9,10 @@ from db.columns.exceptions import InvalidTypeError from mathesar.api.db.permissions.schema import SchemaAccessPolicy from mathesar.api.db.permissions.table import TableAccessPolicy +from db.identifiers import is_identifier_too_long +from mathesar.api.exceptions.database_exceptions import ( + exceptions as database_api_exceptions +) from mathesar.api.exceptions.validation_exceptions.exceptions import ( ColumnSizeMismatchAPIException, DistinctColumnRequiredAPIException, @@ -176,6 +180,11 @@ def update(self, instance, validated_data): raise base_api_exceptions.ValueAPIException(e, status_code=status.HTTP_400_BAD_REQUEST) return instance + def validate_name(self, name): + if is_identifier_too_long(name): + raise database_api_exceptions.IdentifierTooLong(field='name') + return name + def validate(self, data): if self.partial: if table_name := data.get('name', None): diff --git a/mathesar/api/ui/viewsets/__init__.py b/mathesar/api/ui/viewsets/__init__.py index 38f7376eb6..6982d6a4c7 100644 --- a/mathesar/api/ui/viewsets/__init__.py +++ b/mathesar/api/ui/viewsets/__init__.py @@ -1,3 +1,4 @@ from mathesar.api.ui.viewsets.databases import DatabaseViewSet # noqa from mathesar.api.ui.viewsets.users import * # noqa from mathesar.api.ui.viewsets.version import VersionViewSet # noqa +from mathesar.api.ui.viewsets.records import RecordViewSet # noqa diff --git a/mathesar/api/ui/viewsets/records.py b/mathesar/api/ui/viewsets/records.py new file mode 100644 index 0000000000..e59a1f9ece --- /dev/null +++ b/mathesar/api/ui/viewsets/records.py @@ -0,0 +1,35 @@ +from psycopg2.errors import ForeignKeyViolation +from rest_access_policy import AccessViewSetMixin +from rest_framework import status, viewsets +from rest_framework.response import Response +from rest_framework.decorators import action +from sqlalchemy.exc import IntegrityError + +from mathesar.api.db.permissions.records import RecordAccessPolicy +import mathesar.api.exceptions.database_exceptions.exceptions as database_api_exceptions + +from mathesar.api.utils import get_table_or_404 +from mathesar.models.base import Table + + +class RecordViewSet(AccessViewSetMixin, viewsets.GenericViewSet): + access_policy = RecordAccessPolicy + + def get_queryset(self): + return Table.objects.all().order_by('-created_at') + + @action(methods=['post'], detail=False) + def delete(self, request, table_pk=None): + table = get_table_or_404(table_pk) + pks = request.data.get("pks") + try: + table.bulk_delete_records(pks) + except IntegrityError as e: + if isinstance(e.orig, ForeignKeyViolation): + raise database_api_exceptions.ForeignKeyViolationAPIException( + e, + status_code=status.HTTP_400_BAD_REQUEST, + referent_table=table, + ) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/mathesar/imports/csv.py b/mathesar/imports/csv.py index 12979e4721..19c8d8f950 100644 --- a/mathesar/imports/csv.py +++ b/mathesar/imports/csv.py @@ -2,6 +2,7 @@ import clevercsv as csv +from db.identifiers import truncate_if_necessary from db.tables.operations.alter import update_pk_sequence_to_latest from mathesar.database.base import create_mathesar_engine from mathesar.models.base import Table @@ -118,15 +119,7 @@ def create_db_table_from_data_file(data_file, name, schema, comment=None): encoding = get_file_encoding(data_file.file) with open(sv_filename, 'rb') as sv_file: sv_reader = get_sv_reader(sv_file, header, dialect=dialect) - column_names = [column_name.strip() for column_name in sv_reader.fieldnames] - column_names = [ - f"{COLUMN_NAME_TEMPLATE}{i}" if name == '' else name - for i, name in enumerate(column_names) - ] - column_names_alt = [ - fieldname if fieldname != ID else ID_ORIGINAL - for fieldname in column_names - ] + column_names = _process_column_names(sv_reader.fieldnames) table = create_string_column_table( name=name, schema=schema.name, @@ -149,6 +142,10 @@ def create_db_table_from_data_file(data_file, name, schema, comment=None): update_pk_sequence_to_latest(engine, table) except (IntegrityError, DataError): drop_table(name=name, schema=schema.name, engine=engine) + column_names_alt = [ + column_name if column_name != ID else ID_ORIGINAL + for column_name in column_names + ] table = create_string_column_table( name=name, schema=schema.name, @@ -171,6 +168,25 @@ def create_db_table_from_data_file(data_file, name, schema, comment=None): return table +def _process_column_names(column_names): + column_names = ( + column_name.strip() + for column_name + in column_names + ) + column_names = ( + truncate_if_necessary(column_name) + for column_name + in column_names + ) + column_names = ( + f"{COLUMN_NAME_TEMPLATE}{i}" if name == '' else name + for i, name + in enumerate(column_names) + ) + return list(column_names) + + def create_table_from_csv(data_file, name, schema, comment=None): engine = create_mathesar_engine(schema.database.name) db_table = create_db_table_from_data_file( diff --git a/mathesar/models/base.py b/mathesar/models/base.py index bfcb3becb6..5bad2c4301 100644 --- a/mathesar/models/base.py +++ b/mathesar/models/base.py @@ -25,7 +25,7 @@ from db.constraints import utils as constraint_utils from db.dependents.dependents_utils import get_dependents_graph, has_dependents from db.metadata import get_empty_metadata -from db.records.operations.delete import delete_record +from db.records.operations.delete import bulk_delete_records, delete_record from db.records.operations.insert import insert_record_or_records from db.records.operations.select import get_column_cast_records, get_count, get_record from db.records.operations.select import get_records @@ -526,6 +526,9 @@ def update_record(self, id_value, record_data): def delete_record(self, id_value): return delete_record(self._sa_table, self.schema._sa_engine, id_value) + def bulk_delete_records(self, id_values): + return bulk_delete_records(self._sa_table, self.schema._sa_engine, id_values) + def add_constraint(self, constraint_obj): create_constraint( self._sa_table.schema, diff --git a/mathesar/templates/mathesar/login_base.html b/mathesar/templates/mathesar/login_base.html index 6de732c1ad..d0f238e95c 100644 --- a/mathesar/templates/mathesar/login_base.html +++ b/mathesar/templates/mathesar/login_base.html @@ -151,7 +151,7 @@ {% block content %} {% if live_demo_mode %} diff --git a/mathesar/tests/api/test_column_api.py b/mathesar/tests/api/test_column_api.py index 3cf7929013..23bed70f9d 100644 --- a/mathesar/tests/api/test_column_api.py +++ b/mathesar/tests/api/test_column_api.py @@ -9,6 +9,9 @@ from mathesar.api.exceptions.error_codes import ErrorCodes from mathesar.tests.api.test_table_api import check_columns_response +from mathesar.api.exceptions.database_exceptions import ( + exceptions as database_api_exceptions +) def test_column_list(column_test_table, client): @@ -153,6 +156,22 @@ def test_column_create(column_test_table, client): assert actual_new_col["default"] is None +def test_column_create_with_long_column_name(column_test_table, client): + very_long_string = ''.join(map(str, range(50))) + name = 'very_long_identifier_' + very_long_string + db_type = PostgresType.NUMERIC + data = { + "name": name, + "type": db_type.id, + } + response = client.post( + f"/api/db/v0/tables/{column_test_table.id}/columns/", + data=data, + ) + assert response.status_code == 400 + assert response.json()[0]['code'] == database_api_exceptions.IdentifierTooLong.error_code + + @pytest.mark.parametrize('client_name, expected_status_code', write_client_with_different_roles) def test_column_create_by_different_roles(create_patents_table, request, client_name, expected_status_code): table_name = 'NASA Constraint List 1' diff --git a/mathesar/tests/api/test_record_api.py b/mathesar/tests/api/test_record_api.py index c52ec6c84b..a752bea0b3 100644 --- a/mathesar/tests/api/test_record_api.py +++ b/mathesar/tests/api/test_record_api.py @@ -1148,6 +1148,62 @@ def test_record_delete_fkey_violation(library_ma_tables, client): assert 'Items' in response_exception['message'] +def test_record_bulk_delete(create_patents_table, client): + table_name = 'NASA Record Delete' + table = create_patents_table(table_name) + records = table.get_records() + original_num_records = len(records) + record_ids = [records[i]['id'] for i in range(1, 4)] + data = { + 'pks': record_ids + } + + response = client.post(f'/api/ui/v0/tables/{table.id}/records/delete/', data=data) + assert response.status_code == 204 + assert len(table.get_records()) == original_num_records - len(record_ids) + + +def test_record_bulk_delete_fkey_violation(library_ma_tables, client): + publications = library_ma_tables['Publications'] + records = publications.get_records() + record_ids = [records[i]['id'] for i in range(1, 4)] + data = { + 'pks': record_ids + } + + response = client.post(f'/api/ui/v0/tables/{publications.id}/records/delete/', data=data) + assert response.status_code == 400 + response_exception = response.json()[0] + assert response_exception['code'] == ErrorCodes.ForeignKeyViolation.value + assert 'Items' in response_exception['message'] + + +def test_record_bulk_delete_atomicity(library_ma_tables, client): + publications = library_ma_tables['Publications'] + items = library_ma_tables['Items'] + item_records = items.get_records() + original_items_num_records = len(item_records) + record_id = item_records[0]['id'] + + response = client.delete(f'/api/db/v0/tables/{items.id}/records/{record_id}/') + assert response.status_code == 204 + assert len(items.get_records()) == original_items_num_records - 1 + + publication_records = publications.get_records() + original_publication_num_records = len(publication_records) + record_ids = [publication_records[i]['id'] for i in range(1, 4)] + data = { + 'pks': record_ids + } + + response = client.post(f'/api/ui/v0/tables/{publications.id}/records/delete/', data=data) + assert response.status_code == 400 + response_exception = response.json()[0] + assert response_exception['code'] == ErrorCodes.ForeignKeyViolation.value + assert 'Items' in response_exception['message'] + assert len(publication_records) == original_publication_num_records + + def test_record_update(create_patents_table, client): table_name = 'NASA Record Put' table = create_patents_table(table_name) diff --git a/mathesar/tests/api/test_schema_api.py b/mathesar/tests/api/test_schema_api.py index 196669af44..381f8c6bc0 100644 --- a/mathesar/tests/api/test_schema_api.py +++ b/mathesar/tests/api/test_schema_api.py @@ -293,6 +293,23 @@ def test_schema_create_by_superuser(client, FUN_create_dj_db, MOD_engine_cache): ) +def test_schema_create_by_superuser_too_long_name(client, FUN_create_dj_db): + db_name = "some_db1" + FUN_create_dj_db(db_name) + schema_count_before = Schema.objects.count() + very_long_string = ''.join(map(str, range(50))) + schema_name = 'very_long_identifier_' + very_long_string + data = { + 'name': schema_name, + 'database': db_name + } + response = client.post('/api/db/v0/schemas/', data=data) + assert response.status_code == 400 + assert response.json()[0]['code'] == ErrorCodes.IdentifierTooLong.value + schema_count_after = Schema.objects.count() + assert schema_count_after == schema_count_before + + def test_schema_create_by_db_manager(client_bob, user_bob, FUN_create_dj_db, get_uid): db_name = get_uid() role = "manager" diff --git a/mathesar/tests/api/test_table_api.py b/mathesar/tests/api/test_table_api.py index 893c74dd0f..0bec6e6796 100644 --- a/mathesar/tests/api/test_table_api.py +++ b/mathesar/tests/api/test_table_api.py @@ -714,6 +714,18 @@ def test_table_create_with_same_name(client, schema): assert response_error[0]['message'] == f'Relation {table_name} already exists in schema {schema.id}' +def test_table_create_with_too_long_name(client, schema): + very_long_string = ''.join(map(str, range(50))) + table_name = 'very_long_identifier_' + very_long_string + body = { + 'name': table_name, + 'schema': schema.id, + } + response = client.post('/api/db/v0/tables/', body) + assert response.status_code == 400 + assert response.json()[0]['code'] == ErrorCodes.IdentifierTooLong.value + + def test_table_create_with_existing_id_col(client, existing_id_col_table_datafile, schema, engine): table_name = "Table 1" response, response_table, table = _create_table( diff --git a/mathesar/tests/data/long_column_names.csv b/mathesar/tests/data/long_column_names.csv new file mode 100644 index 0000000000..5536053791 --- /dev/null +++ b/mathesar/tests/data/long_column_names.csv @@ -0,0 +1,55 @@ +"State or Nation","Cycle 1 Total Number of Health Deficiencies","Cycle 1 Total Number of Fire Safety Deficiencies","Cycle 2 Total Number of Health Deficiencies","Cycle 2 Total Number of Fire Safety Deficiencies","Cycle 3 Total Number of Health Deficiencies","Cycle 3 Total Number of Fire Safety Deficiencies","Average Number of Residents per Day","Reported Nurse Aide Staffing Hours per Resident per Day","Reported LPN Staffing Hours per Resident per Day","Reported RN Staffing Hours per Resident per Day","Reported Licensed Staffing Hours per Resident per Day","Reported Total Nurse Staffing Hours per Resident per Day","Total number of nurse staff hours per resident per day on the weekend","Registered Nurse hours per resident per day on the weekend","Reported Physical Therapist Staffing Hours per Resident Per Day","Total nursing staff turnover","Registered Nurse turnover","Number of administrators who have left the nursing home","Case-Mix RN Staffing Hours per Resident per Day","Case-Mix Total Nurse Staffing Hours per Resident per Day","Number of Fines","Fine Amount in Dollars","Percentage of long stay residents whose need for help with daily activities has increased","Percentage of long stay residents who lose too much weight","Percentage of low risk long stay residents who lose control of their bowels or bladder","Percentage of long stay residents with a catheter inserted and left in their bladder","Percentage of long stay residents with a urinary tract infection","Percentage of long stay residents who have depressive symptoms","Percentage of long stay residents who were physically restrained","Percentage of long stay residents experiencing one or more falls with major injury","Percentage of long stay residents assessed and appropriately given the pneumococcal vaccine","Percentage of long stay residents who received an antipsychotic medication","Percentage of short stay residents assessed and appropriately given the pneumococcal vaccine","Percentage of short stay residents who newly received an antipsychotic medication","Percentage of long stay residents whose ability to move independently worsened","Percentage of long stay residents who received an antianxiety or hypnotic medication","Percentage of high risk long stay residents with pressure ulcers","Percentage of long stay residents assessed and appropriately given the seasonal influenza vaccine","Percentage of short stay residents who made improvements in function","Percentage of short stay residents who were assessed and appropriately given the seasonal influenza vaccine","Percentage of short stay residents who were rehospitalized after a nursing home admission","Percentage of short stay residents who had an outpatient emergency department visit","Number of hospitalizations per 1000 long-stay resident days","Number of outpatient emergency department visits per 1000 long-stay resident days","Processing Date" +"NATION","8.6","4.5","8.5","4.3","8.3","4.6","78.6","2.22","0.88","0.66","1.53","3.75","3.26","0.45","0.07","53.9","52.3","0.8","0.38487","3.15796","2.3","33247","14.842144","6.172333","47.158545","1.698662","2.345577","7.882694","0.145406","3.395302","92.085375","14.447634","78.873848","1.738571","16.161024","19.436701","8.145643","94.937079","74.115131","75.601680","22.073834","11.791045","1.585233","1.016932","2023-02-01" +"AK","8.9","7.3","8.0","6.6","13.1","5.5","35.8","4.26","0.59","2.01","2.60","6.86","5.88","1.44","0.09","54.7","54.0","0.3","0.32843","3.07815","2.8","40982","11.399472","5.955443","42.734961","3.729863","3.092132","7.233612","0.214286","3.780119","94.381998","15.207048","74.994950","0.897709","16.474449","14.673754","9.103530","97.261368","77.508477","81.395239","14.130346","14.433861","1.002547","0.897472","2023-02-01" +"AL","2.9","2.9","3.1","3.3","3.9","3.4","91.3","2.34","0.87","0.58","1.45","3.79","3.15","0.32","0.03","52.2","46.9","0.7","0.30675","2.91289","1.0","9544","12.824604","6.869793","42.223463","2.067217","3.126881","1.119971","0.627098","3.500289","92.692985","19.916009","81.288737","2.412304","14.032358","23.573902","9.394910","93.702772","72.519689","75.399412","21.907646","11.153483","1.641877","0.983719","2023-02-01" +"AR","8.5","2.1","9.2","1.8","11.1","2.2","71.2","2.50","1.02","0.37","1.39","3.89","3.29","0.26","0.03","56.5","54.9","0.5","0.30829","2.91594","1.9","23087","12.018400","5.631477","40.901992","1.622935","2.248033","2.173623","0.229492","3.957339","94.849162","11.978126","78.050791","1.685996","11.258485","21.562726","8.438663","96.916093","77.938874","73.836436","23.566078","13.385911","1.830761","1.296204","2023-02-01" +"AZ","7.6","3.3","7.0","3.2","8.0","3.3","76.0","2.28","1.08","0.75","1.82","4.10","3.55","0.52","0.11","55.0","55.0","0.5","0.39392","3.15730","2.0","13117","12.840441","6.067455","56.507921","1.539109","1.507067","3.595873","0.310559","2.456865","95.641211","10.356776","86.951457","1.056651","18.691552","17.508812","9.226697","94.685482","74.158771","83.771004","23.490408","11.388180","1.275856","0.956408","2023-02-01" +"CA","15.1","7.1","14.1","6.8","13.1","6.8","80.9","2.54","1.20","0.58","1.79","4.33","3.88","0.44","0.08","47.8","51.5","0.8","0.40762","3.15304","2.2","19392","8.128644","5.245266","31.959431","1.618842","1.273445","5.524313","0.231156","1.768723","97.972113","10.004216","93.461148","1.289304","11.198966","13.731006","7.546983","98.394685","79.463056","92.036398","21.788146","10.829473","1.722919","0.832184","2023-02-01" +"CO","7.6","6.1","7.5","6.2","9.9","6.0","66.3","2.16","0.68","0.84","1.52","3.68","3.22","0.62","0.08","58.5","56.8","1.2","0.35462","3.01904","3.6","47574","15.465715","6.301084","47.560332","1.943715","2.062230","3.872010","0.088626","3.633371","86.832813","17.676144","71.128281","1.719544","16.945294","12.513949","5.861102","93.915574","75.203028","69.259789","19.332534","12.842127","1.183063","0.924672","2023-02-01" +"CT","7.9","3.3","8.7","2.1","9.8","2.8","96.1","2.13","0.81","0.72","1.52","3.65","3.20","0.48","0.09","43.6","45.6","0.7","0.37347","3.12293","2.1","26299","17.142969","6.602093","42.812501","1.285110","2.264424","7.452102","0.006252","3.541306","84.747229","15.665749","62.698501","1.471289","19.217666","17.268224","6.452903","93.046692","74.507476","64.082133","23.019671","11.607396","1.720229","0.869883","2023-02-01" +"DC","14.4","2.4","16.4","2.5","16.1","4.6","117.7","2.46","0.59","1.55","2.15","4.61","3.98","1.25","0.11","46.3","44.0","0.7","0.53050","3.50084","2.8","90079","17.149334","5.072200","64.161295","1.323201","1.687977","0.829892","0.169683","1.229977","90.680541","7.962449","65.172847","1.668955","22.407608","10.783063","13.164346","95.659753","65.283356","70.271605","19.374209","6.763558","0.929341","0.325031","2023-02-01" +"DE","11.4","0.9","13.3","0.8","15.5","1.4","84.6","2.30","0.94","1.06","2.00","4.30","3.75","0.73","0.10","46.9","44.6","0.6","0.34697","3.03371","1.5","24843","15.009389","4.850518","51.780924","0.761590","1.721781","4.803085","0.190354","4.056899","95.177121","11.092806","80.869836","1.009524","18.304275","18.201550","5.977196","97.253134","76.179021","81.259924","19.549109","12.075911","1.457310","0.730632","2023-02-01" +"FL","5.9","1.8","5.6","2.7","6.7","3.5","100.2","2.40","0.85","0.68","1.54","3.93","3.54","0.47","0.09","54.0","58.4","0.9","0.35502","3.09968","2.1","27981","12.816814","6.365373","52.264518","1.134168","1.796867","2.448652","0.017971","2.804979","97.076653","10.386811","90.147105","1.726172","14.941135","21.530673","9.270504","96.952495","78.078477","87.700518","25.296906","9.591155","1.915698","0.691537","2023-02-01" +"GA","4.1","1.3","4.1","2.8","3.9","3.3","80.5","1.90","1.04","0.45","1.49","3.39","2.90","0.28","0.05","54.0","52.6","0.8","0.36572","3.12253","2.1","25067","17.040645","6.798043","42.320798","1.686013","3.169320","5.132865","0.069017","3.323895","92.367287","18.235999","79.363199","2.826814","18.303896","20.802715","10.509768","95.279234","69.422210","75.692177","21.757265","11.722100","1.646802","1.041544","2023-02-01" +"GU","20.0","8.0","13.0","5.0","13.0","10.0","15.2","4.19","1.68","4.10","5.78","9.97","9.11","3.55","0.32",,,,"0.67516","3.34519","3.0","7150",,,,,,,,,,,"97.163122","8.333332",,,,"70.833333","0.000000","91.452991","24.816053","0.000000",,,"2023-02-01" +"HI","11.4","2.2","9.6","3.2","8.6","0.7","74.6","2.72","0.34","1.60","1.94","4.66","4.15","1.25","0.07","39.3","43.4","0.8","0.38766","3.23198","2.5","41427","12.348540","5.191199","47.941947","1.853915","2.481894","1.479999","0.345567","1.639112","96.267609","9.441430","78.896335","1.257852","19.499854","8.656161","4.854205","97.745290","77.616533","77.288302","15.798930","8.019240","1.013812","0.675099","2023-02-01" +"IA","7.6","6.2","6.4","5.1","6.1","5.6","47.7","2.36","0.59","0.74","1.34","3.69","3.19","0.50","0.04","56.1","50.7","0.8","0.32422","2.94033","2.5","31362","17.182500","5.503345","45.826977","2.905904","3.211425","4.620689","0.234481","3.661753","94.486830","17.036856","84.754686","1.696546","17.689218","19.334326","6.198473","95.693380","78.295246","74.139313","20.062687","12.770796","1.354242","1.165986","2023-02-01" +"ID","8.2","4.6","10.3","4.5","10.4","4.2","47.1","2.49","0.83","0.95","1.78","4.27","3.61","0.64","0.14","56.1","51.0","0.7","0.37520","3.14779","2.5","33492","12.924392","5.824486","46.843756","1.784242","2.388714","9.129729","0.366979","3.501341","96.634971","17.707444","86.341184","1.204967","16.319169","15.595508","6.164639","96.364004","74.936058","81.483404","14.573690","11.210045","0.901025","1.001252","2023-02-01" +"IL","11.7","6.5","11.0","8.0","9.4","8.0","84.8","1.98","0.65","0.72","1.37","3.35","2.91","0.53","0.06","52.1","49.8","0.9","0.41900","3.31127","3.3","82957","13.806874","6.641194","44.309725","1.776029","2.565415","32.133572","0.101770","3.463801","85.381931","17.796274","65.381111","2.063073","15.655724","18.842671","8.756588","91.656850","70.257559","62.457472","24.553063","12.930031","1.788210","1.163074","2023-02-01" +"IN","8.9","6.9","9.6","7.1","9.6","6.8","66.8","2.13","0.79","0.63","1.42","3.55","3.05","0.41","0.06","57.4","53.4","0.7","0.42895","3.39945","2.6","30277","16.282996","6.252093","56.243292","0.931282","1.805943","14.403894","0.076208","4.067471","90.536625","12.883658","76.996226","1.609374","13.844641","22.110261","7.300250","94.211093","73.988901","72.941627","21.401472","12.099129","1.484595","1.110387","2023-02-01" +"KS","8.0","9.8","6.3","9.3","7.1","11.3","47.4","2.57","0.67","0.72","1.39","3.97","3.46","0.50","0.05","57.3","52.1","0.7","0.33705","2.96194","2.4","23551","17.270619","5.726627","41.596896","2.485671","3.635786","5.957237","0.059274","5.083958","88.856452","17.844800","71.643663","2.252982","18.470156","22.772910","6.972764","94.759783","74.145134","67.158045","20.934086","11.560256","1.687413","1.241434","2023-02-01" +"KY","4.6","1.9","5.5","1.4","4.9","0.9","74.3","2.25","0.86","0.77","1.64","3.89","3.36","0.50","0.06","56.7","50.0","0.7","0.41963","3.30694","1.6","40177","16.053339","7.800501","45.536515","1.538687","3.040351","8.842789","0.227049","4.119532","91.810084","16.179459","79.047690","2.173845","18.002202","29.375386","9.274942","95.133746","71.989882","76.797540","22.417735","14.350174","1.708204","1.389017","2023-02-01" +"LA","6.1","1.3","4.1","0.9","5.0","1.0","85.4","2.20","1.14","0.27","1.41","3.61","3.06","0.18","0.05","55.4","53.6","0.9","0.38369","3.09516","2.0","16894","18.656230","5.663137","39.565836","1.731458","2.810300","1.498618","0.270675","3.423500","93.077418","16.621426","81.679050","2.581402","14.256986","22.552108","9.601170","94.265139","65.089414","73.692261","25.552806","13.440326","2.206644","1.537023","2023-02-01" +"MA","11.6","2.6","9.5","2.0","9.6","1.9","90.9","2.10","0.92","0.67","1.59","3.69","3.24","0.47","0.07","47.1","52.8","0.8","0.31907","3.07685","2.7","50457","15.723910","5.724830","57.595477","1.467799","2.403211","2.008722","0.170192","3.730504","89.843750","19.521781","73.777879","1.508874","16.115730","18.586347","6.565019","95.019709","71.546852","74.994018","23.636202","11.430013","1.546126","0.805229","2023-02-01" +"MD","14.4","5.1","14.7","3.6","11.6","3.8","99.0","2.08","0.87","0.83","1.70","3.78","3.30","0.59","0.09","49.3","50.9","0.9","0.42591","3.32068","1.8","21990","20.331978","6.173061","63.941173","1.413939","2.086239","11.160903","0.122735","2.582015","89.935951","13.134876","74.321669","1.542654","25.781949","14.155775","10.105588","95.268467","73.059782","76.285525","20.975714","9.484874","1.149602","0.709799","2023-02-01" +"ME","6.4","4.9","5.7","4.0","5.7","5.4","58.0","2.87","0.47","1.00","1.48","4.34","3.83","0.69","0.08","55.5","50.8","0.8","0.31218","3.16030","1.0","7382","13.638926","6.025590","65.142410","2.004786","3.395025","8.676855","0.052568","4.127018","90.498288","20.603068","67.179995","1.495056","20.860618","16.306428","6.147884","95.563935","70.159342","73.078954","15.817526","14.276760","1.006634","1.146582","2023-02-01" +"MI","12.7","6.1","13.8","5.4","13.1","4.8","76.8","2.23","0.89","0.75","1.64","3.87","3.34","0.47","0.06","53.1","48.4","0.9","0.32755","3.01248","2.6","69596","12.304438","6.631126","47.835367","1.610109","2.299351","3.402342","0.146837","2.924258","92.242691","13.632260","79.105072","1.332810","15.182324","18.966396","8.849317","92.961463","77.621248","74.864522","22.459666","11.431663","1.598690","0.918488","2023-02-01" +"MN","6.7","4.5","8.6","3.5","9.4","3.4","56.3","2.38","0.66","1.01","1.67","4.05","3.48","0.63","0.07","50.9","47.2","0.6","0.30696","2.97545","2.4","38927","13.739490","4.743802","49.695572","2.918130","2.862371","4.374829","0.076392","4.018921","96.032326","16.585568","88.092258","1.826142","16.459135","12.601455","6.810617","95.146963","77.097943","77.600065","20.614291","13.533464","1.359289","0.961614","2023-02-01" +"MO","10.7","7.0","9.6","4.6","8.4","5.3","67.1","2.12","0.70","0.46","1.16","3.28","2.86","0.32","0.04","60.3","58.5","0.8","0.31571","2.78326","3.0","35865","15.459187","5.413499","30.087970","2.183881","3.297052","4.646042","0.118577","3.827940","86.108015","20.687883","67.949211","2.549121","13.767195","23.985574","9.445319","92.278732","72.307599","64.118590","23.853437","12.073895","1.761408","1.209387","2023-02-01" +"MS","3.9","0.6","4.5","0.4","4.1","0.7","69.4","2.32","1.09","0.59","1.68","4.00","3.36","0.35","0.05","52.4","49.9","0.7","0.34129","3.05183","1.1","21152","19.214683","6.958171","48.472827","1.942004","2.993915","2.040307","0.467353","3.094010","96.072347","20.591187","84.781258","3.337471","19.151819","22.715151","10.231306","96.417677","68.900694","79.245312","23.818034","15.014332","2.066777","1.523809","2023-02-01" +"MT","6.9","7.8","6.5","10.8","7.8","12.2","46.6","2.28","0.57","0.83","1.41","3.69","3.27","0.61","0.06","63.2","55.9","0.9","0.30381","2.83834","4.3","64624","15.897640","7.377114","42.449845","2.945208","2.960927","5.103359","0.228091","4.921630","90.655136","17.775999","75.340530","1.699699","17.451727","14.567369","8.471330","94.336078","74.622658","69.289414","15.661370","12.570224","1.197952","1.253237","2023-02-01" +"NC","7.6","2.0","6.0","2.7","6.0","4.0","78.0","2.20","0.89","0.57","1.46","3.66","3.15","0.37","0.08","57.2","54.8","0.8","0.40680","3.23533","1.8","44631","19.368996","8.365626","53.842000","1.462936","3.298314","3.419275","0.056298","3.779915","88.411617","11.988600","76.066907","1.483103","22.918309","21.400159","10.276281","93.055159","72.619948","74.185894","20.928803","12.743005","1.427248","1.075012","2023-02-01" +"ND","5.9","1.5","5.6","1.3","5.8","2.0","59.5","2.87","0.61","0.86","1.47","4.34","3.65","0.52","0.03","53.1","40.8","0.5","0.29070","2.94563","1.2","12419","16.393833","5.473313","48.268205","1.997814","3.509871","5.421254","0.193185","4.570133","98.238410","20.217778","91.943488","2.721974","15.731838","18.752673","5.869866","98.301952","77.585936","84.642634","17.287770","9.944697","1.270855","0.954934","2023-02-01" +"NE","7.4","6.3","6.3","4.6","8.9","6.5","51.9","2.55","0.68","0.72","1.40","3.95","3.39","0.50","0.05","56.1","47.5","0.6","0.33541","3.02373","1.9","19653","14.762205","5.825405","48.168379","2.289872","3.784967","4.156021","0.174891","4.819080","91.735755","18.843531","79.508421","1.790478","17.085793","19.739101","5.876530","95.958044","78.644474","72.319867","19.872370","10.487467","1.565626","0.991274","2023-02-01" +"NH","4.8","3.1","3.8","2.6","3.4","2.7","76.6","2.30","0.81","0.72","1.53","3.83","3.32","0.47","0.06","51.7","45.6","0.5","0.35018","3.07170","1.3","10862","17.985373","6.453707","47.445124","1.694464","2.661148","5.833451","0.225576","4.927813","93.195146","16.636614","78.343891","1.443100","19.562980","16.461981","6.487035","96.852235","79.661243","76.564362","19.780502","13.562729","1.359127","1.056185","2023-02-01" +"NJ","4.3","2.8","3.7","1.2","4.8","1.6","110.4","2.11","0.92","0.75","1.67","3.78","3.30","0.52","0.10","48.8","49.8","1.0","0.37014","3.05687","2.2","31780","10.457140","6.003672","41.742823","1.258171","1.493034","6.499913","0.103787","2.594331","93.606364","10.538616","80.683178","1.195082","10.681691","17.429732","9.083879","96.519125","81.174676","80.212170","23.532855","8.990347","1.791158","0.720861","2023-02-01" +"NM","15.7","3.9","12.4","4.2","10.8","4.6","74.0","2.27","0.69","0.65","1.34","3.60","3.17","0.47","0.08","61.2","64.5","0.9","0.33007","2.92979","3.4","54788","16.880726","6.974591","42.691992","2.212342","1.689994","3.268364","0.047687","3.344050","95.710454","16.752933","78.284619","1.760241","17.587717","13.044343","8.225322","95.091577","67.042592","74.459302","18.994139","16.282381","1.341497","1.503519","2023-02-01" +"NV","13.5","12.5","14.9","11.5","15.8","14.0","82.6","2.33","1.01","0.84","1.84","4.17","3.63","0.60","0.11","53.7","52.5","0.9","0.44537","3.18521","3.9","19457","15.613568","5.810421","39.745229","2.348736","2.082659","2.638951","0.186568","2.681275","91.584943","13.595163","79.367319","1.786471","18.670537","18.029114","8.509120","91.560315","69.506874","75.616935","20.984294","9.430053","1.333119","0.654221","2023-02-01" +"NY","4.9","4.5","5.5","3.9","4.9","4.4","157.8","2.08","0.78","0.66","1.44","3.52","2.99","0.43","0.12","45.0","47.2","0.6","0.47563","3.43071","1.5","9742","15.066503","6.417714","54.453754","1.156857","2.109533","13.099350","0.203698","3.010254","89.287753","11.614134","70.707866","1.413465","15.399499","13.317674","9.528866","95.009824","75.573860","73.840557","19.143212","9.340678","1.396627","0.742529","2023-02-01" +"OH","10.1","6.0","10.5","5.4","7.9","4.4","68.2","1.96","0.94","0.60","1.54","3.50","3.07","0.39","0.06","58.1","54.5","0.8","0.46852","3.38465","2.3","31735","14.965475","6.857397","43.246040","0.965583","1.502662","21.593891","0.053769","3.667924","89.503534","13.075572","73.916728","1.695244","14.012745","22.485032","7.411900","92.505303","75.215028","69.647646","23.028039","12.755315","1.407392","1.061040","2023-02-01" +"OK","7.6","4.3","8.9","4.0","8.1","4.4","55.5","2.46","0.95","0.34","1.29","3.76","3.34","0.27","0.02","61.0","60.4","0.7","0.31539","2.81726","3.2","23013","13.895305","4.488022","36.933077","2.527743","3.529558","4.213974","0.097056","4.232689","91.466497","14.176484","75.929068","2.076984","13.214458","24.398642","8.977589","95.142884","72.537256","70.803339","24.843107","16.633618","1.921517","1.510100","2023-02-01" +"OR","9.7","5.4","13.9","5.2","9.8","4.3","47.9","3.17","0.93","0.74","1.67","4.83","4.21","0.45","0.07","54.6","57.9","0.9","0.32562","3.07921","2.3","32471","13.308442","6.389020","51.941615","2.617117","2.431452","5.745072","0.185699","2.685213","94.926349","15.629524","82.965184","1.415088","20.172390","11.943572","8.776540","93.718219","75.768330","77.416431","18.273032","15.092847","1.203002","1.385720","2023-02-01" +"PA","8.0","5.1","8.3","5.5","8.4","5.4","96.0","2.03","0.87","0.78","1.65","3.68","3.26","0.54","0.09","50.8","48.6","0.9","0.41560","3.32079","1.8","26519","15.197922","6.622871","55.267584","1.551957","1.933698","2.951130","0.212735","3.348926","87.934847","15.708643","70.684062","1.486137","19.322636","19.718950","7.379146","94.139844","72.521877","70.032500","20.988444","9.326240","1.397721","0.672073","2023-02-01" +"PR","4.4","6.7","5.8","5.8","3.0","10.6","23.2","0.00","1.39","2.85","4.24","4.24","3.35","2.05","0.46","46.3","46.6","0.0","0.40967","2.99292","5.8","45874",,,,,,,,,,,"92.917090","0.648318",,,,,"77.337036","97.667416",,,,,"2023-02-01" +"RI","9.4","0.3","7.2","0.5","4.2","0.5","90.3","2.35","0.43","0.82","1.25","3.59","3.13","0.58","0.06","49.8","48.8","1.0","0.35556","3.05791","2.9","52981","16.642184","6.047845","45.177640","1.240739","2.869224","2.185293","0.087767","3.832213","91.629133","18.238520","72.640066","1.492483","18.642817","14.973592","7.519538","95.740607","76.334540","75.367531","23.154435","13.624046","1.267121","0.841478","2023-02-01" +"SC","4.1","0.7","4.3","0.4","7.8","0.8","83.1","2.24","1.03","0.64","1.67","3.91","3.39","0.40","0.07","57.7","54.0","0.8","0.33566","3.03900","1.7","17843","14.164727","7.366458","56.441614","1.365320","2.875399","2.837575","0.241224","3.287655","92.143804","14.863037","80.608406","1.833134","18.000137","19.891573","9.662829","94.396477","72.465571","77.092520","23.314670","12.731835","1.765981","1.048664","2023-02-01" +"SD","4.6","1.5","5.0","1.8","4.5","1.2","48.8","2.33","0.44","0.79","1.23","3.56","3.03","0.51","0.05","53.8","41.5","0.7","0.30817","2.93111","1.5","18554","16.834104","5.976653","51.417000","2.720656","3.291942","5.264478","0.143154","5.459047","95.028219","19.889467","85.487463","1.752324","18.448584","15.008498","6.788145","96.995369","80.949700","77.533005","16.528802","11.616449","1.264200","0.841901","2023-02-01" +"TN","5.4","2.0","4.4","3.0","4.0","3.4","77.2","2.05","1.14","0.57","1.70","3.75","3.19","0.35","0.08","55.7","51.3","0.9","0.39973","3.25675","1.3","32227","16.810657","7.716838","52.765450","1.615227","3.073131","9.865683","0.185103","3.401994","88.845066","15.912567","75.656471","1.912190","21.131910","29.392660","8.691131","93.233403","73.199093","73.972533","20.181561","11.435387","1.326567","0.979845","2023-02-01" +"TX","6.2","2.2","6.4","3.3","7.6","4.1","69.5","1.96","1.05","0.37","1.42","3.38","2.93","0.28","0.06","59.6","63.2","0.8","0.41931","3.22391","2.9","40448","17.074427","5.029710","52.196518","1.567410","1.416943","5.039136","0.043473","3.349034","95.502194","10.582499","83.231536","1.884036","14.435950","22.055441","7.508460","96.323202","66.829074","78.081775","23.965753","12.226630","1.777216","1.126636","2023-02-01" +"UT","9.4","3.3","9.0","3.7","9.8","4.3","55.3","2.32","0.55","1.16","1.71","4.02","3.49","0.86","0.12","60.8","50.3","0.8","0.45973","3.32571","2.7","27251","13.928882","5.526550","45.600098","1.718026","2.895718","10.177801","0.060164","3.226548","95.637162","13.366906","89.303507","1.329467","16.885494","20.695680","6.323916","95.588038","71.460153","84.549547","18.248338","11.729645","0.984483","0.807143","2023-02-01" +"VA","11.3","4.5","11.5","4.5","11.5","5.1","92.5","1.95","1.02","0.62","1.63","3.59","3.09","0.40","0.08","56.5","55.5","1.1","0.40404","3.21630","1.6","18869","17.088182","7.173330","52.658001","1.136915","2.622878","4.366234","0.135625","3.526327","88.492761","13.836493","70.953070","1.637843","19.632030","20.288508","8.688855","94.426334","74.984786","72.172020","21.472724","12.399274","1.353924","1.005582","2023-02-01" +"VT","7.4","2.5","4.7","2.5","4.5","1.1","67.7","2.35","1.00","0.72","1.72","4.07","3.55","0.46","0.08","61.3","56.7","0.7","0.37203","3.19644","1.6","18110","19.695843","6.495890","48.690671","2.038359","2.892317","13.645847","0.109464","5.059416","91.166759","17.128111","80.112576","1.925287","22.700096","16.323132","7.682717","95.385608","79.195423","75.541526","17.119589","13.717848","1.197579","1.462406","2023-02-01" +"WA","18.0","8.1","20.0","6.0","17.6","7.6","64.8","2.52","0.79","0.90","1.69","4.21","3.56","0.62","0.09","54.9","53.1","1.0","0.39495","3.30419","2.5","66656","14.634771","6.167716","54.567819","1.994137","2.317844","13.320128","0.328683","2.767774","95.106071","13.810903","84.581613","1.364470","19.892669","12.209977","6.936666","94.905943","73.869151","79.524549","16.574808","11.553737","1.019159","0.836404","2023-02-01" +"WI","7.9","6.2","7.3","4.6","7.2","4.2","52.0","2.28","0.58","0.96","1.54","3.82","3.34","0.66","0.07","53.1","46.3","0.6","0.36456","3.15373","2.1","41912","13.690116","5.695565","50.198605","3.069005","3.102199","6.290265","0.079362","3.238508","95.575805","14.099089","87.490647","1.143070","18.218220","15.397054","7.371773","95.116789","75.984216","79.213494","19.847340","13.503943","1.294014","1.303517","2023-02-01" +"WV","11.3","2.4","10.3","2.4","10.2","2.9","73.5","2.11","0.96","0.69","1.65","3.76","3.22","0.36","0.05","52.6","44.1","0.5","0.40919","3.24531","1.2","20821","16.293913","8.265190","44.432807","1.870960","3.791073","4.469620","0.143698","4.827009","95.376320","16.726475","78.814057","1.676784","20.695138","24.155520","9.562136","95.960581","71.442064","74.423412","20.816512","13.386938","1.531578","1.202551","2023-02-01" +"WY","6.0","6.3","6.2","3.9","8.3","3.3","56.5","2.25","0.54","0.86","1.40","3.65","3.10","0.63","0.05","58.4","48.8","0.4","0.34348","2.98457","2.4","20675","15.041404","7.127500","45.861098","2.375188","3.778759","7.096262","0.253890","4.172703","96.198127","19.817071","79.418052","2.199333","15.663748","14.880130","6.360152","96.324929","79.122969","73.355561","14.338753","14.813702","1.098024","1.308557","2023-02-01" diff --git a/mathesar/tests/imports/test_csv.py b/mathesar/tests/imports/test_csv.py index 70ae4b4b46..c039417320 100644 --- a/mathesar/tests/imports/test_csv.py +++ b/mathesar/tests/imports/test_csv.py @@ -21,6 +21,14 @@ def data_file(patents_csv_filepath): return data_file +@pytest.fixture +def long_column_data_file(): + data_filepath = 'mathesar/tests/data/long_column_names.csv' + with open(data_filepath, "rb") as csv_file: + data_file = DataFile.objects.create(file=File(csv_file)) + return data_file + + @pytest.fixture def headerless_data_file(headerless_patents_csv_filepath): with open(headerless_patents_csv_filepath, "rb") as csv_file: @@ -57,7 +65,8 @@ def check_csv_upload(table, table_name, schema, num_records, row, cols): assert table.schema == schema assert table.sa_num_records() == num_records assert table.get_records()[0] == row - assert all([col in table.sa_column_names for col in cols]) + for col in cols: + assert col in table.sa_column_names def test_csv_upload(data_file, schema): @@ -89,6 +98,74 @@ def test_csv_upload(data_file, schema): ) +def test_csv_upload_long_columns(long_column_data_file, schema): + table_name = "long_cols" + table = create_table_from_csv(long_column_data_file, table_name, schema) + + num_records = 54 + expected_row = ( + 1, 'NATION', '8.6', '4.5', '8.5', '4.3', '8.3', '4.6', '78.6', '2.22', + '0.88', '0.66', '1.53', '3.75', '3.26', '0.45', '0.07', '53.9', '52.3', + '0.8', '0.38487', '3.15796', '2.3', '33247', '14.842144', '6.172333', + '47.158545', '1.698662', '2.345577', '7.882694', '0.145406', '3.395302', + '92.085375', '14.447634', '78.873848', '1.738571', '16.161024', + '19.436701', '8.145643', '94.937079', '74.115131', '75.601680', + '22.073834', '11.791045', '1.585233', '1.016932', '2023-02-01', + ) + expected_cols = [ + 'id', + 'State or Nation', + 'Cycle 1 Total Number of Health Deficiencies', + 'Cycle 1 Total Number of Fire Safety Deficiencies', + 'Cycle 2 Total Number of Health Deficiencies', + 'Cycle 2 Total Number of Fire Safety Deficiencies', + 'Cycle 3 Total Number of Health Deficiencies', + 'Cycle 3 Total Number of Fire Safety Deficiencies', + 'Average Number of Residents per Day', + 'Reported Nurse Aide Staffing Hours per Resident per Day', + 'Reported LPN Staffing Hours per Resident per Day', + 'Reported RN Staffing Hours per Resident per Day', + 'Reported Licensed Staffing Hours per Resident per Day', + 'Reported Total Nurse Staffing Hours per Resident per Day', + 'Total number of nurse staff hours per resident per day-8cd5ab5e', + 'Registered Nurse hours per resident per day on the weekend', + 'Reported Physical Therapist Staffing Hours per Resident Per Day', + 'Total nursing staff turnover', + 'Registered Nurse turnover', + 'Number of administrators who have left the nursing home', + 'Case-Mix RN Staffing Hours per Resident per Day', + 'Case-Mix Total Nurse Staffing Hours per Resident per Day', + 'Number of Fines', + 'Fine Amount in Dollars', + 'Percentage of long stay residents whose need for help-5c97c88f', + 'Percentage of long stay residents who lose too much weight', + 'Percentage of low risk long stay residents who lose co-fc6bc241', + 'Percentage of long stay residents with a catheter inse-ce71f22a', + 'Percentage of long stay residents with a urinary tract-f16fbec8', + 'Percentage of long stay residents who have depressive symptoms', + 'Percentage of long stay residents who were physically-f30de0aa', + 'Percentage of long stay residents experiencing one or-9f9e8f36', + 'Percentage of long stay residents assessed and appropr-84744861', + 'Percentage of long stay residents who received an anti-20fe5d12', + 'Percentage of short stay residents assessed and approp-3568770f', + 'Percentage of short stay residents who newly received-e98612b4', + 'Percentage of long stay residents whose ability to mov-66839cb4', + 'Percentage of long stay residents who received an anti-868593e4', + 'Percentage of high risk long stay residents with press-b624bbba', + 'Percentage of long stay residents assessed and appropr-999c26ef', + 'Percentage of short stay residents who made improvemen-ebe5c21e', + 'Percentage of short stay residents who were assessed a-26e64965', + 'Percentage of short stay residents who were rehospital-682a4dae', + 'Percentage of short stay residents who had an outpatie-9403ec21', + 'Number of hospitalizations per 1000 long-stay resident days', + 'Number of outpatient emergency department visits per 1-f0fed7b5', + 'Processing Date' + ] + check_csv_upload( + table, table_name, schema, num_records, expected_row, expected_cols + ) + + def test_headerless_csv_upload(headerless_data_file, schema): table_name = "NASA no headers" table = create_table_from_csv(headerless_data_file, table_name, schema) diff --git a/mathesar/urls.py b/mathesar/urls.py index 223175d335..6f776359e4 100644 --- a/mathesar/urls.py +++ b/mathesar/urls.py @@ -28,10 +28,14 @@ ui_router.register(r'database_roles', ui_viewsets.DatabaseRoleViewSet, basename='database_role') ui_router.register(r'schema_roles', ui_viewsets.SchemaRoleViewSet, basename='schema_role') +ui_table_router = routers.NestedSimpleRouter(db_router, r'tables', lookup='table') +ui_table_router.register(r'records', ui_viewsets.RecordViewSet, basename='table-record') + urlpatterns = [ path('api/db/v0/', include(db_router.urls)), path('api/db/v0/', include(db_table_router.urls)), path('api/ui/v0/', include(ui_router.urls)), + path('api/ui/v0/', include(ui_table_router.urls)), path('api/ui/v0/reflect/', views.reflect_all, name='reflect_all'), path('auth/password_reset_confirm', MathesarPasswordResetConfirmView.as_view(), name='password_reset_confirm'), path('auth/login/', LoginView.as_view(redirect_authenticated_user=True), name='login'), diff --git a/mathesar/users/password_reset.py b/mathesar/users/password_reset.py index 7ea2e0ab89..49e93cc5f0 100644 --- a/mathesar/users/password_reset.py +++ b/mathesar/users/password_reset.py @@ -10,7 +10,7 @@ class MathesarSetPasswordForm(SetPasswordForm): def save(self, commit=True): password = self.cleaned_data["new_password1"] self.user.set_password(password) - # Default password is replaced with a password is set by the user, so change the status + # Default password is replaced with a password which is set by the user, so change the status self.user.password_change_needed = False if commit: self.user.save() diff --git a/mathesar_ui/README.md b/mathesar_ui/README.md index c47975bb05..edbdc6b78f 100644 --- a/mathesar_ui/README.md +++ b/mathesar_ui/README.md @@ -72,13 +72,13 @@ If you don't have your editor configured to auto-format your code, then you'll n - Format all front end files ``` - docker exec -it -w /code/mathesar_ui mathesar_service npm run format + docker exec -it -w /code/mathesar_ui mathesar_service_dev npm run format ``` - Format a specific file ``` - docker exec -it -w /code/mathesar_ui mathesar_service npx prettier --write src/App.svelte + docker exec -it -w /code/mathesar_ui mathesar_service_dev npx prettier --write src/App.svelte ``` ## Linting @@ -88,13 +88,13 @@ We use [ESLint](https://eslint.org/) to help spot more complex issues within cod - Lint all front end files: ``` - docker exec -it -w /code/mathesar_ui mathesar_service npm run lint + docker exec -it -w /code/mathesar_ui mathesar_service_dev npm run lint ``` - Lint a specific file: ``` - docker exec -it -w /code/mathesar_ui mathesar_service npx eslint src/App.svelte + docker exec -it -w /code/mathesar_ui mathesar_service_dev npx eslint src/App.svelte ``` ## Testing @@ -112,13 +112,13 @@ We use [Vitest](https://vitest.dev/) to run our unit tests, and we use [Testing - Run all our tests: ``` - docker exec -it -w /code/mathesar_ui mathesar_service npm test + docker exec -it -w /code/mathesar_ui mathesar_service_dev npm test ``` - Re-run a specific test by name: ``` - docker exec -it -w /code/mathesar_ui mathesar_service npm run test TextInput + docker exec -it -w /code/mathesar_ui mathesar_service_dev npm run test TextInput ``` This will run all test files with file names containing `TextInput`. @@ -165,7 +165,7 @@ If you want to add or remove packages, or basically run any npm action, **always 1. Connect to the container and open the ui folder: ```bash - docker exec -it mathesar_service /bin/bash + docker exec -it mathesar_service_dev /bin/bash cd mathesar_ui ``` @@ -207,13 +207,13 @@ We use [Storybook](https://storybook.js.org/) to develop and document our compon - **Start** Storybook in dev mode with: ```bash - docker exec -it -w /code/mathesar_ui mathesar_service npm run storybook + docker exec -it -w /code/mathesar_ui mathesar_service_dev npm run storybook ``` - **Build** Storybook with: ```bash - docker exec -it -w /code/mathesar_ui mathesar_service npm run build-storybook + docker exec -it -w /code/mathesar_ui mathesar_service_dev npm run build-storybook ``` ## Coding standards diff --git a/mathesar_ui/src/component-library/common/actions/popper.ts b/mathesar_ui/src/component-library/common/actions/popper.ts index fbc88f6d97..1419e325dc 100644 --- a/mathesar_ui/src/component-library/common/actions/popper.ts +++ b/mathesar_ui/src/component-library/common/actions/popper.ts @@ -13,6 +13,63 @@ interface Parameters { options?: Partial; } +/** + * Merge the default modifiers with the supplied modifiers, ensuring that there + * are no duplicates, by modifier name. When a modifier with the same name + * occurs, use the supplied modifier instead of the default modifier. + */ +function buildModifiers( + suppliedModifiers: Options['modifiers'], +): Options['modifiers'] { + const defaultModifiers: Options['modifiers'] = [ + { + name: 'flip', + }, + { + name: 'preventOverflow', + options: { + altAxis: true, + }, + }, + { + name: 'offset', + options: { + offset: [0, 0], + }, + }, + ]; + const customModifiers: Options['modifiers'] = [ + { + name: 'setMinWidth', + enabled: true, + phase: 'beforeWrite', + requires: ['computeStyles'], + fn: (obj: ModifierArguments>): void => { + // TODO: Make the default value configurable + const widthToSet = Math.min(250, obj.state.rects.reference.width); + // eslint-disable-next-line no-param-reassign + obj.state.styles.popper.minWidth = `${widthToSet}px`; + }, + effect: (obj: ModifierArguments>): void => { + const width = (obj.state.elements.reference as HTMLElement).offsetWidth; + const widthToSet = Math.min(250, width); + // eslint-disable-next-line no-param-reassign + obj.state.elements.popper.style.minWidth = `${widthToSet}px`; + }, + }, + ]; + const modifiers = [...defaultModifiers, ...customModifiers]; + suppliedModifiers.forEach((modifier) => { + const index = modifiers.findIndex((m) => m.name === modifier.name); + if (index === -1) { + modifiers.push(modifier); + } else { + modifiers[index] = modifier; + } + }); + return modifiers; +} + export default function popper( node: HTMLElement, actionOpts: Parameters, @@ -27,38 +84,7 @@ export default function popper( // eslint-disable-next-line @typescript-eslint/no-unsafe-call popperInstance = createPopper(reference, node, { placement: options?.placement || 'bottom-start', - modifiers: [ - { - name: 'setMinWidth', - enabled: true, - phase: 'beforeWrite', - requires: ['computeStyles'], - // @ts-ignore: https://github.com/centerofci/mathesar/issues/1055 - fn: (obj: ModifierArguments): void => { - // TODO: Make the default value configurable - const widthToSet = Math.min(250, obj.state.rects.reference.width); - // eslint-disable-next-line no-param-reassign - obj.state.styles.popper.minWidth = `${widthToSet}px`; - }, - // @ts-ignore: https://github.com/centerofci/mathesar/issues/1055 - effect: (obj: ModifierArguments): void => { - const width = (obj.state.elements.reference as HTMLElement) - .offsetWidth; - const widthToSet = Math.min(250, width); - // eslint-disable-next-line no-param-reassign - obj.state.elements.popper.style.minWidth = `${widthToSet}px`; - }, - }, - { - name: 'flip', - }, - { - name: 'offset', - options: { - offset: [0, 0], - }, - }, - ], + modifiers: buildModifiers(options?.modifiers ?? []), }) as Instance; } diff --git a/mathesar_ui/src/component-library/common/utils/index.ts b/mathesar_ui/src/component-library/common/utils/index.ts index 6d29e0fd15..24bc24a805 100644 --- a/mathesar_ui/src/component-library/common/utils/index.ts +++ b/mathesar_ui/src/component-library/common/utils/index.ts @@ -19,3 +19,4 @@ export * from './inputUtils'; export * from './typeUtils'; export * from './stringUtils'; export * from './miscUtils'; +export * from './styleUtils'; diff --git a/mathesar_ui/src/component-library/common/utils/styleUtils.ts b/mathesar_ui/src/component-library/common/utils/styleUtils.ts new file mode 100644 index 0000000000..059b780001 --- /dev/null +++ b/mathesar_ui/src/component-library/common/utils/styleUtils.ts @@ -0,0 +1,24 @@ +import type { CssVariablesObj } from '@mathesar-component-library-dir/types'; +import { isDefinedNonNullable } from './typeUtils'; + +const isCssVariable = (str: string) => str.indexOf('--') === 0; + +export function makeStyleStringFromCssVariables(cssVariables: CssVariablesObj) { + return Object.entries(cssVariables) + .filter((maybeCssVariable) => isCssVariable(maybeCssVariable[0])) + .map((cssVariable) => `${cssVariable[0]}: ${cssVariable[1]};`) + .join(''); +} + +export function mergeStyleStrings(...args: (string | undefined)[]) { + return args + .filter(isDefinedNonNullable) + .map((styleString) => { + const trimmedStyleString = styleString.trim(); + if (trimmedStyleString.endsWith(';')) { + return trimmedStyleString; + } + return `${trimmedStyleString};`; + }) + .join(''); +} diff --git a/mathesar_ui/src/component-library/commonTypes.ts b/mathesar_ui/src/component-library/commonTypes.ts index af365c2e9a..7e7321f36d 100644 --- a/mathesar_ui/src/component-library/commonTypes.ts +++ b/mathesar_ui/src/component-library/commonTypes.ts @@ -11,3 +11,4 @@ export type Size = 'small' | 'medium' | 'large'; type InputProps = svelte.JSX.HTMLAttributes; export type SimplifiedInputProps = Omit; +export type CssVariablesObj = Record; diff --git a/mathesar_ui/src/component-library/confirmation/Confirmation.svelte b/mathesar_ui/src/component-library/confirmation/Confirmation.svelte index 994be122cf..5d96de3797 100644 --- a/mathesar_ui/src/component-library/confirmation/Confirmation.svelte +++ b/mathesar_ui/src/component-library/confirmation/Confirmation.svelte @@ -34,6 +34,7 @@ } catch (error) { // @ts-ignore: https://github.com/centerofci/mathesar/issues/1055 onError(error); + modal.close(); } finally { allowClose = true; } diff --git a/mathesar_ui/src/component-library/dropdown/AttachableDropdown.svelte b/mathesar_ui/src/component-library/dropdown/AttachableDropdown.svelte index 244321394e..a8564fa65c 100644 --- a/mathesar_ui/src/component-library/dropdown/AttachableDropdown.svelte +++ b/mathesar_ui/src/component-library/dropdown/AttachableDropdown.svelte @@ -18,7 +18,18 @@ const dispatch = createEventDispatcher(); export let trigger: HTMLElement | undefined = undefined; - export let placement: Placement = 'bottom-start'; + /** + * These `Placement` values will be tried in sequence until a placement is + * found that does not cause the dropdown content to overflow the viewport. + */ + export let placements: Placement[] = [ + 'bottom-start', + 'bottom-end', + 'top-start', + 'top-end', + 'right-start', + 'left-start', + ]; export let isOpen = false; export let classes = ''; export { classes as class }; @@ -28,6 +39,17 @@ let contentElement: HTMLElement | undefined; + $: placement = placements[0] ?? 'bottom-start'; + $: fallbackPlacements = (() => { + const p = placements.slice(1); + if (p.length === 0) { + // If we didn't get any fallback placements, we want to send `undefined` + // to popper so that popper uses its default fallback placements. + return undefined; + } + return p; + })(); + const parentAccompanyingElements = getContext< AccompanyingElements | undefined >('dropdownAccompanyingElements'); @@ -90,7 +112,17 @@ use:portal use:popper={{ reference: trigger, - options: { placement }, + options: { + placement, + modifiers: [ + { + name: 'flip', + options: { + fallbackPlacements, + }, + }, + ], + }, }} use:clickOffBounds={{ callback: close, diff --git a/mathesar_ui/src/component-library/dropdown/Dropdown.scss b/mathesar_ui/src/component-library/dropdown/Dropdown.scss index 27c4c999cb..781a31478e 100644 --- a/mathesar_ui/src/component-library/dropdown/Dropdown.scss +++ b/mathesar_ui/src/component-library/dropdown/Dropdown.scss @@ -30,7 +30,7 @@ button.dropdown.trigger { border-radius: var(--border-radius-m); background: var(--dropdown-background, var(--white)); z-index: var(--dropdown-z-index, 100); - max-height: calc(100vh - 5em); + max-height: calc(100vh - 0.5rem); overflow: auto; &[data-popper-placement='top-start'] { diff --git a/mathesar_ui/src/component-library/dropdown/Dropdown.svelte b/mathesar_ui/src/component-library/dropdown/Dropdown.svelte index 03d0a7386b..307a305fd6 100644 --- a/mathesar_ui/src/component-library/dropdown/Dropdown.svelte +++ b/mathesar_ui/src/component-library/dropdown/Dropdown.svelte @@ -17,7 +17,7 @@ export let closeOnInnerClick = false; export let ariaLabel: string | undefined = undefined; export let ariaControls: string | undefined = undefined; - export let placement: Placement = 'bottom-start'; + export let placements: Placement[] | undefined = undefined; export let showArrow = true; export let size: Size = 'medium'; @@ -41,13 +41,10 @@ function toggle(e: Event) { /** - * To make it work when wrapped with an anchor tag. - * Since the event is already not bubbling up - * and this component will always necessarily handle the click event, - * preventing the default behaviour should be fine + * If this Dropdown is used in a form we should prevent the default + * behavior of submitting the form. */ e.preventDefault(); - e.stopPropagation(); isOpen = !isOpen; } @@ -84,7 +81,7 @@ {#if loading} -
+
{/if} diff --git a/mathesar_ui/src/component-library/text-input/TextInput.svelte b/mathesar_ui/src/component-library/text-input/TextInput.svelte index 30eb14848b..476b56b18d 100644 --- a/mathesar_ui/src/component-library/text-input/TextInput.svelte +++ b/mathesar_ui/src/component-library/text-input/TextInput.svelte @@ -1,5 +1,9 @@ @@ -29,6 +39,7 @@ ; diff --git a/mathesar_ui/src/component-library/tooltip/Tooltip.svelte b/mathesar_ui/src/component-library/tooltip/Tooltip.svelte index 09af1686d5..ad804a1637 100644 --- a/mathesar_ui/src/component-library/tooltip/Tooltip.svelte +++ b/mathesar_ui/src/component-library/tooltip/Tooltip.svelte @@ -24,7 +24,7 @@ diff --git a/mathesar_ui/src/component-library/tooltip/TooltipAction.ts b/mathesar_ui/src/component-library/tooltip/TooltipAction.ts index a65e94ba6c..d8ee99b697 100644 --- a/mathesar_ui/src/component-library/tooltip/TooltipAction.ts +++ b/mathesar_ui/src/component-library/tooltip/TooltipAction.ts @@ -11,7 +11,7 @@ export default function tooltip( const tooltipComponent = new AttachableDropdown({ props: { trigger: node, - placement: 'top', + placements: ['top', 'right', 'bottom', 'left'], content, class: 'tooltip', }, diff --git a/mathesar_ui/src/component-library/truncate/Truncate.svelte b/mathesar_ui/src/component-library/truncate/Truncate.svelte index baad14f777..ec1cdce589 100644 --- a/mathesar_ui/src/component-library/truncate/Truncate.svelte +++ b/mathesar_ui/src/component-library/truncate/Truncate.svelte @@ -9,7 +9,13 @@ * true. */ export let passthrough = false; - export let popoverPlacement: Placement = 'top'; + /** @see the `placements` prop in AttachableDropdown */ + export let popoverPlacements: Placement[] = [ + 'top', + 'right', + 'left', + 'bottom', + ]; let element: HTMLSpanElement; let dropdownIsOpen = false; @@ -57,7 +63,7 @@ diff --git a/mathesar_ui/src/pages/schema/EntityLayout.svelte b/mathesar_ui/src/components/EntityContainerWithFilterBar.svelte similarity index 100% rename from mathesar_ui/src/pages/schema/EntityLayout.svelte rename to mathesar_ui/src/components/EntityContainerWithFilterBar.svelte diff --git a/mathesar_ui/src/components/breadcrumb/BreadcrumbItem.svelte b/mathesar_ui/src/components/breadcrumb/BreadcrumbItem.svelte index 1d4de4d7f3..731c136c7b 100644 --- a/mathesar_ui/src/components/breadcrumb/BreadcrumbItem.svelte +++ b/mathesar_ui/src/components/breadcrumb/BreadcrumbItem.svelte @@ -56,7 +56,7 @@ item.database.name, item.schema.id, item.table.id, - item.record.id, + item.record.pk, )} > diff --git a/mathesar_ui/src/components/breadcrumb/breadcrumbTypes.ts b/mathesar_ui/src/components/breadcrumb/breadcrumbTypes.ts index 04b2e23fcf..b18a6ea385 100644 --- a/mathesar_ui/src/components/breadcrumb/breadcrumbTypes.ts +++ b/mathesar_ui/src/components/breadcrumb/breadcrumbTypes.ts @@ -37,7 +37,7 @@ export interface BreadcrumbItemRecord { schema: SchemaEntry; table: TableEntry; record: { - id: number; + pk: string; summary: string; }; } diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/SteppedInputCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/SteppedInputCell.svelte index c3b5a426c1..78fdab64bf 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/SteppedInputCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/SteppedInputCell.svelte @@ -1,5 +1,5 @@ diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/money/MoneyCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/money/MoneyCell.svelte index e6d182c6fe..35197c456d 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/money/MoneyCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/money/MoneyCell.svelte @@ -16,7 +16,7 @@ diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/number/NumberCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/number/NumberCell.svelte index 4c056866aa..66cc70c963 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/number/NumberCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/number/NumberCell.svelte @@ -20,7 +20,7 @@ diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/textarea/TextAreaCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/textarea/TextAreaCell.svelte index ae0befc61c..891c6ef3df 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/textarea/TextAreaCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/textarea/TextAreaCell.svelte @@ -30,7 +30,7 @@ + import { makeStyleStringFromCssVariables } from '@mathesar-component-library'; import AppHeader from '@mathesar/components/AppHeader.svelte'; import LiveDemoBanner from '@mathesar/components/LiveDemoBanner.svelte'; @@ -7,10 +8,7 @@ export let cssVariables: Record | undefined = undefined; $: style = cssVariables - ? Object.entries(cssVariables) - .filter((val) => val[0].indexOf('--') === 0) - .map((entry) => `${entry[0]}: ${entry[1]}`) - .join(';') + ? makeStyleStringFromCssVariables(cssVariables) : undefined; diff --git a/mathesar_ui/src/pages/admin-users/AdminNavigation.svelte b/mathesar_ui/src/pages/admin-users/AdminNavigation.svelte index b94670e80f..bb52f3b440 100644 --- a/mathesar_ui/src/pages/admin-users/AdminNavigation.svelte +++ b/mathesar_ui/src/pages/admin-users/AdminNavigation.svelte @@ -2,7 +2,7 @@ import { active } from 'tinro'; import { Icon } from '@mathesar-component-library'; - import { iconSettingsMajor, iconUser } from '@mathesar/icons'; + import { iconSettingsMajor, iconMultipleUsers } from '@mathesar/icons'; import { ADMIN_UPDATE_PAGE_URL, ADMIN_USERS_PAGE_URL, @@ -23,7 +23,7 @@
  • - + Users
  • diff --git a/mathesar_ui/src/pages/admin-users/EditUserPage.svelte b/mathesar_ui/src/pages/admin-users/EditUserPage.svelte index 17fdc2321e..7aee62c35e 100644 --- a/mathesar_ui/src/pages/admin-users/EditUserPage.svelte +++ b/mathesar_ui/src/pages/admin-users/EditUserPage.svelte @@ -1,8 +1,11 @@ + +

    New User

    diff --git a/mathesar_ui/src/pages/admin-users/UserListingPage.svelte b/mathesar_ui/src/pages/admin-users/UserListingPage.svelte index 2aadda0041..f804651d5e 100644 --- a/mathesar_ui/src/pages/admin-users/UserListingPage.svelte +++ b/mathesar_ui/src/pages/admin-users/UserListingPage.svelte @@ -1,18 +1,15 @@ + +
    + +
    + + diff --git a/mathesar_ui/src/pages/database/SchemaRow.svelte b/mathesar_ui/src/pages/database/SchemaRow.svelte index feb6a11bff..dbabfca324 100644 --- a/mathesar_ui/src/pages/database/SchemaRow.svelte +++ b/mathesar_ui/src/pages/database/SchemaRow.svelte @@ -22,19 +22,21 @@ export let schema: SchemaEntry; export let canExecuteDDL = true; + let isHovered = false; + $: href = getSchemaPageUrl(database.name, schema.id); $: isDefault = schema.name === 'public'; $: isLocked = schema.name === 'public'; - -
    -
    - +
    +
    +
    - {#if isLocked} - - {:else if canExecuteDDL} + {#if isLocked} +
    + {:else if canExecuteDDL} + - - {#if schema.description} -

    - {schema.description} -

    - {/if} - - - - {#if isDefault} - - Every PostgreSQL database includes the "public" schema. This protected - schema can be read by anybody who accesses the database. - +
    {/if}
    -
    - - diff --git a/mathesar_ui/src/systems/table-view/row/GroupHeaderCellValue.svelte b/mathesar_ui/src/systems/table-view/row/GroupHeaderCellValue.svelte index b295a7c1ff..e42d92ea4f 100644 --- a/mathesar_ui/src/systems/table-view/row/GroupHeaderCellValue.svelte +++ b/mathesar_ui/src/systems/table-view/row/GroupHeaderCellValue.svelte @@ -4,6 +4,7 @@ import type { RecordSummariesForSheet } from '@mathesar/stores/table-data/record-summaries/recordSummaryUtils'; import type { ResultValue } from '@mathesar/api/types/tables/records'; import LinkedRecord from '@mathesar/components/LinkedRecord.svelte'; + import { storeToGetRecordPageUrl } from '@mathesar/stores/storeBasedUrls'; export let processedColumnsMap: Map; export let recordSummariesForSheet: RecordSummariesForSheet; @@ -15,6 +16,10 @@ $: recordSummary = recordSummariesForSheet .get(String(columnId)) ?.get(recordId); + $: recordPageHref = $storeToGetRecordPageUrl({ + tableId: processedColumnsMap?.get(columnId)?.linkFk?.referent_table, + recordId, + }); @@ -26,7 +31,7 @@ {#if recordSummary} - + {:else} {/if} @@ -35,18 +40,13 @@ diff --git a/mathesar_ui/src/systems/users-and-permissions/AccessControlView.svelte b/mathesar_ui/src/systems/users-and-permissions/AccessControlView.svelte index 400c6062a0..eeaf55b571 100644 --- a/mathesar_ui/src/systems/users-and-permissions/AccessControlView.svelte +++ b/mathesar_ui/src/systems/users-and-permissions/AccessControlView.svelte @@ -12,6 +12,7 @@ import { getDisplayNameForRole, type ObjectRoleMap, + type AccessControlObject, } from '@mathesar/utils/permissions'; import type { UserModel } from '@mathesar/stores/users'; import { getErrorMessage } from '@mathesar/utils/errors'; @@ -31,7 +32,7 @@ role: UserRole, ) => Promise; export let removeAccessForUser: (user: UserModel) => Promise; - export let accessControlObject: 'database' | 'schema'; + export let accessControlObject: AccessControlObject; export let getUserRoles: (user: UserModel) => ObjectRoleMap | undefined; $: usersAllowedToBeAdded = usersWithoutAccess.filter( @@ -176,8 +177,11 @@ .list { margin-top: var(--size-base); display: grid; - grid-template-columns: 6fr auto 2.1rem; + grid-template-columns: 6fr 5fr 1fr 3rem; align-items: center; + border: 1px solid var(--slate-200); + border-radius: var(--border-radius-m); + background: var(--white); } } .no-users { diff --git a/mathesar_ui/src/utils/Url64.ts b/mathesar_ui/src/utils/Url64.ts index 6658876816..acf0b19969 100644 --- a/mathesar_ui/src/utils/Url64.ts +++ b/mathesar_ui/src/utils/Url64.ts @@ -1,17 +1,26 @@ /** * Encode a string as a URL-safe base64 string. * - * The resulting string won't have any characters which require URL encoding. - * We change `+` to `-` and `/` to `_`. We remove the padding characters `=` + * The resulting string won't have any characters which require URL encoding. We + * change `+` to `-` and `/` to `_`. We remove the padding characters `=` * altogether because atob only needs them when inputs are concatenated. + * + * TODO: + * + * - Refactor this code to remove use of deprecated function `escape` and + * `unescape`. + * - See: https://developer.mozilla.org/en-US/docs/Glossary/Base64 + * - Consider using a 3rd party library. Maybe `base64-js`, `base64url` or + * `utf8`. */ export default class Url64 { static encode(s: string): string { - const base64 = btoa(s); + const base64 = btoa(unescape(encodeURIComponent(s))); return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } static decode(s: string): string { - return atob(s.replace(/-/g, '+').replace(/_/g, '/')); + const base64 = s.replace(/-/g, '+').replace(/_/g, '/'); + return decodeURIComponent(escape(atob(base64))); } } diff --git a/mathesar_ui/src/utils/permissions.ts b/mathesar_ui/src/utils/permissions.ts index 017c007d91..5bf547a06c 100644 --- a/mathesar_ui/src/utils/permissions.ts +++ b/mathesar_ui/src/utils/permissions.ts @@ -41,6 +41,13 @@ export function roleAllowsOperation( } } +export function rolesAllowOperation( + accessOperation: AccessOperation, + roles: UserRole[], +): boolean { + return roles.some((role) => roleAllowsOperation(role, accessOperation)); +} + export function getDisplayNameForRole(userRole: UserRole): string { switch (userRole) { case 'manager': @@ -54,4 +61,53 @@ export function getDisplayNameForRole(userRole: UserRole): string { } } -export type ObjectRoleMap = Map<'database' | 'schema', UserRole>; +export function getDescriptionForRole(userRole: UserRole): string { + switch (userRole) { + case 'manager': + return 'Manager Access'; + case 'editor': + return 'Editor Access'; + case 'viewer': + return 'Read-Only Access'; + default: + throw new MissingExhaustiveConditionError(userRole); + } +} + +export type AccessControlObject = 'database' | 'schema'; + +export type ObjectRoleMap = Map; + +/** + * Orders roles for numerical comparison. Highest number means higher + * access levels. + */ +const userRoleToLevelInInteger = { + viewer: 1, + editor: 2, + manager: 3, +}; + +export function getObjectWithHighestPrecedenceByRoles( + objectRoleMap: ObjectRoleMap, +): AccessControlObject { + const schemaRole = objectRoleMap.get('schema'); + const databaseRole = objectRoleMap.get('database'); + if (schemaRole && databaseRole) { + if ( + userRoleToLevelInInteger[schemaRole] > + userRoleToLevelInInteger[databaseRole] + ) { + return 'schema'; + } + return 'database'; + } + if (schemaRole) { + return 'schema'; + } + if (databaseRole) { + return 'database'; + } + // Defaults to database when both roles are undefined + return 'database'; +} diff --git a/mathesar_ui/src/utils/typeUtils.ts b/mathesar_ui/src/utils/typeUtils.ts index f5c61f6c0f..ecfa1c7053 100644 --- a/mathesar_ui/src/utils/typeUtils.ts +++ b/mathesar_ui/src/utils/typeUtils.ts @@ -1,5 +1,4 @@ import type { Readable, Writable } from 'svelte/store'; - import { MissingExhaustiveConditionError } from './errors'; /** diff --git a/requirements.txt b/requirements.txt index de3a8a707c..d4c337e7e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,5 +21,6 @@ SQLAlchemy==1.4.26 responses==0.22.0 SQLAlchemy-Utils==0.38.2 thefuzz==0.19.0 +whitenoise==6.4.0 git+https://github.com/centerofci/sqlalchemy-filters@models_to_tables#egg=sqlalchemy_filters gunicorn==20.1.0 \ No newline at end of file