From b7f15a8f51fbcf6e0436ca5194bb46bf7ce3f4f3 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Mon, 25 May 2026 04:03:29 +0300 Subject: [PATCH 1/3] Add specs for dialyzer and eqwalizer --- .env | 7 +- .github/workflows/static-analysis.yml | 35 ++++ .tool-versions | 2 +- Dockerfile.dev | 7 + Makefile | 6 + apps/dmt/src/dmt_api_woody_utils.erl | 57 +++++- apps/dmt/src/dmt_app.erl | 8 +- apps/dmt/src/dmt_author.erl | 14 ++ apps/dmt/src/dmt_author_database.erl | 33 ++- apps/dmt/src/dmt_author_handler.erl | 15 +- apps/dmt/src/dmt_database.erl | 190 ++++++++++++++++-- apps/dmt/src/dmt_db_migration.erl | 85 +++++--- apps/dmt/src/dmt_json.erl | 8 +- apps/dmt/src/dmt_mapper.erl | 54 ++++- apps/dmt/src/dmt_repository.erl | 135 +++++++++++-- .../dmt/src/dmt_repository_client_handler.erl | 15 +- apps/dmt/src/dmt_repository_handler.erl | 66 ++++-- apps/dmt/src/dmt_sup.erl | 99 +++++++-- apps/dmt/src/dmt_thrift.erl | 19 +- apps/dmt/src/dmt_thrift_validator.erl | 11 +- apps/dmt/test/dmt_client_api.erl | 10 +- apps/dmt/test/dmt_repository_filter_test.erl | 2 + apps/dmt_core/src/dmt_domain.erl | 72 ++++++- apps/dmt_core/src/dmt_domain_pt.erl | 8 +- apps/dmt_object/src/dmt_object.erl | 88 ++++---- apps/dmt_object/src/dmt_object_id.erl | 6 + apps/dmt_object/src/dmt_object_reference.erl | 56 +++++- apps/dmt_object/src/dmt_object_type.erl | 2 + compose.yaml | 2 + rebar.config | 3 +- 30 files changed, 939 insertions(+), 176 deletions(-) create mode 100644 .github/workflows/static-analysis.yml diff --git a/.env b/.env index 741626b..6ebd014 100644 --- a/.env +++ b/.env @@ -1,5 +1,8 @@ SERVICE_NAME=dmt -OTP_VERSION=28 +OTP_VERSION=28.5 REBAR_VERSION=3.25 THRIFT_VERSION=0.14.2.3 -CONFLUENT_PLATFORM_VERSION=5.1.2 \ No newline at end of file +CONFLUENT_PLATFORM_VERSION=5.1.2 +# DATABASE_URL=postgresql://postgres:postgres@dmt_db:5432/dmt +ELP_VERSION=2026-02-27 +ELP_OTP_VERSION=28 diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..fd70ebe --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,35 @@ +name: Static analysis + +on: + push: + branches: + - "master" + - "epic/**" + pull_request: + branches: ["**"] + +jobs: + eqwalizer: + name: Eqwalizer + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Load .env + run: grep -v '^#' .env >> "$GITHUB_ENV" + + - name: Build dev image + run: make dev-image + + - name: Run eqwalizer + run: | + make wc-eqwalizer 2>&1 | tee /tmp/eqwalizer.log + # `elp eqwalize-all` exits 0 even when it reports errors; fail the + # job ourselves if any module produced errors. + if grep -qE "^[0-9]+ ERRORS" /tmp/eqwalizer.log; then + echo "::error::Eqwalizer reported errors" + exit 1 + fi diff --git a/.tool-versions b/.tool-versions index 840f52f..37b1884 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ erlang 28.0 -rebar 3.23.0 +rebar 3.25.0 diff --git a/Dockerfile.dev b/Dockerfile.dev index 4c0b0f0..2d3aeef 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -9,6 +9,13 @@ ARG TARGETARCH RUN wget -q -O- "https://github.com/valitydev/thrift/releases/download/${THRIFT_VERSION}/thrift-${THRIFT_VERSION}-linux-${TARGETARCH}.tar.gz" \ | tar -xvz -C /usr/local/bin/ +# Install ELP (Erlang Language Platform) for eqWAlizer +ARG ELP_VERSION +ARG ELP_OTP_VERSION +RUN ELP_ARCH=$([ "${TARGETARCH}" = "arm64" ] && echo "aarch64" || echo "x86_64") && \ + wget -q -O- "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-${ELP_OTP_VERSION}.tar.gz" \ + | tar -xvz -C /usr/local/bin/ + RUN apt-get update && apt-get install -y cmake # Set env diff --git a/Makefile b/Makefile index 6c0b767..358b3ee 100644 --- a/Makefile +++ b/Makefile @@ -92,6 +92,12 @@ check-format: dialyze: $(REBAR) as test dialyzer +eqwalizer: + $(REBAR) compile + # ERL_LIBS lets elp's erlang_service load compiled parse_transforms + # (e.g. dmt_domain_pt) when analysing modules that use them. + ERL_LIBS=$(CURDIR)/_build/default/lib elp eqwalize-all + release: $(REBAR) as prod release diff --git a/apps/dmt/src/dmt_api_woody_utils.erl b/apps/dmt/src/dmt_api_woody_utils.erl index ce3fbd2..c73af2d 100644 --- a/apps/dmt/src/dmt_api_woody_utils.erl +++ b/apps/dmt/src/dmt_api_woody_utils.erl @@ -5,12 +5,51 @@ %% API +-type service_opts() :: #{ + url := binary() | list(), + transport_opts => term(), + resolver_opts => term() +}. + +%% Options passed to every thrift handler via woody's `handlers` config. +-type handler_options() :: #{default_handling_timeout := timeout(), atom() => term()}. + +-export_type([service_opts/0, handler_options/0]). + -spec get_woody_client(atom()) -> woody_client:options(). get_woody_client(Service) -> - Services = genlib_app:env(dmt_api, services, #{}), + Services = get_services(genlib_app:env(dmt_api, services, #{})), make_woody_client(maps:get(Service, Services)). --spec make_woody_client(#{atom() => _}) -> woody_client:options(). +-spec get_services(term()) -> #{atom() => service_opts()}. +get_services(Map) when is_map(Map) -> + maps:fold(fun fold_service/3, #{}, Map); +get_services(_) -> + #{}. + +-spec fold_service(term(), term(), #{atom() => service_opts()}) -> + #{atom() => service_opts()}. +fold_service(K, #{url := Url} = V, Acc) when is_atom(K), is_binary(Url) -> + Acc#{K => build_service_opts(V, Url)}; +fold_service(K, #{url := Url} = V, Acc) when is_atom(K), is_list(Url) -> + Acc#{K => build_service_opts(V, Url)}; +fold_service(_, _, Acc) -> + Acc. + +-spec build_service_opts(map(), binary() | list()) -> service_opts(). +build_service_opts(V, Url) -> + Base = #{url => Url}, + Base1 = + case maps:find(transport_opts, V) of + {ok, T} -> Base#{transport_opts => T}; + error -> Base + end, + case maps:find(resolver_opts, V) of + {ok, R} -> Base1#{resolver_opts => R}; + error -> Base1 + end. + +-spec make_woody_client(service_opts()) -> woody_client:options(). make_woody_client(#{url := Url} = Service) -> lists:foldl( fun(Opt, Acc) -> @@ -31,7 +70,19 @@ make_woody_client(#{url := Url} = Service) -> -spec get_woody_event_handlers() -> woody:ev_handlers(). get_woody_event_handlers() -> - genlib_app:env(dmt_api, woody_event_handlers, [scoper_woody_event_handler]). + Default = [scoper_woody_event_handler], + case genlib_app:env(dmt_api, woody_event_handlers, Default) of + Handler when is_atom(Handler) -> Handler; + {Mod, Opts} when is_atom(Mod) -> {Mod, Opts}; + [_ | _] = List -> [ensure_ev_handler(H) || H <- List]; + [] -> Default; + _ -> Default + end. + +-spec ensure_ev_handler(term()) -> woody:ev_handler() | no_return(). +ensure_ev_handler(Mod) when is_atom(Mod) -> Mod; +ensure_ev_handler({Mod, Opts}) when is_atom(Mod) -> {Mod, Opts}; +ensure_ev_handler(Other) -> erlang:error({bad_ev_handler, Other}). -spec ensure_woody_deadline_set(woody_context:ctx(), woody_deadline:deadline()) -> woody_context:ctx(). ensure_woody_deadline_set(WoodyContext, Default) -> diff --git a/apps/dmt/src/dmt_app.erl b/apps/dmt/src/dmt_app.erl index 57b060c..7f5a766 100644 --- a/apps/dmt/src/dmt_app.erl +++ b/apps/dmt/src/dmt_app.erl @@ -9,9 +9,15 @@ -export([start/2, stop/1]). +-spec start(application:start_type(), term()) -> + {ok, pid()} | {ok, pid(), term()} | {error, term()}. start(_StartType, _StartArgs) -> - dmt_sup:start_link(). + case dmt_sup:start_link() of + ignore -> {error, ignore}; + Other -> Other + end. +-spec stop(term()) -> ok. stop(_State) -> ok. diff --git a/apps/dmt/src/dmt_author.erl b/apps/dmt/src/dmt_author.erl index 68e0643..08c8736 100644 --- a/apps/dmt/src/dmt_author.erl +++ b/apps/dmt/src/dmt_author.erl @@ -2,6 +2,8 @@ %% Public API +-include_lib("damsel/include/dmsl_domain_conf_v2_thrift.hrl"). + -define(POOL_NAME, author_pool). -export([ @@ -11,21 +13,33 @@ delete/1 ]). +-type author_id() :: binary(). +-type name() :: binary(). +-type email() :: binary(). +-type author() :: dmsl_domain_conf_v2_thrift:'Author'(). + +-export_type([author_id/0, name/0, email/0, author/0]). + %% Optional: Extended API (can be uncommented if these functions should be exposed) %% -export([ %% list/2, %% search/2 %% ]). +-spec insert(name(), email()) -> + {ok, author_id()} | {ok, {already_exists, author_id()}} | {error, unknown}. insert(Name, Email) -> dmt_author_database:insert(?POOL_NAME, Name, Email). +-spec get(author_id()) -> {ok, author()} | {error, author_not_found | term()}. get(AuthorID) -> dmt_author_database:get(?POOL_NAME, AuthorID). +-spec get_by_email(email()) -> {ok, author()} | {error, author_not_found | term()}. get_by_email(Email) -> dmt_author_database:get_by_email(?POOL_NAME, Email). +-spec delete(author_id()) -> ok | {error, author_not_found | term()}. delete(AuthorID) -> dmt_author_database:delete(?POOL_NAME, AuthorID). diff --git a/apps/dmt/src/dmt_author_database.erl b/apps/dmt/src/dmt_author_database.erl index 081e4a3..d615555 100644 --- a/apps/dmt/src/dmt_author_database.erl +++ b/apps/dmt/src/dmt_author_database.erl @@ -13,6 +13,14 @@ search/3 ]). +-type worker() :: dmt_database:worker(). +-type author_id() :: dmt_author:author_id(). +-type name() :: dmt_author:name(). +-type email() :: dmt_author:email(). +-type author() :: dmt_author:author(). + +-spec insert(worker(), name(), email()) -> + {ok, author_id()} | {ok, {already_exists, author_id()}} | {error, unknown}. insert(Worker, Name, Email) -> Sql = """ INSERT INTO author (name, email) @@ -32,6 +40,7 @@ insert(Worker, Name, Email) -> {error, unknown} end. +-spec get(worker(), author_id()) -> {ok, author()} | {error, author_not_found | term()}. get(Worker, AuthorID) -> case is_uuid(AuthorID) of true -> @@ -40,6 +49,7 @@ get(Worker, AuthorID) -> {error, author_not_found} end. +-spec get_(worker(), author_id()) -> {ok, author()} | {error, author_not_found | term()}. get_(Worker, AuthorID) -> Sql = """ SELECT id, name, email @@ -60,6 +70,7 @@ get_(Worker, AuthorID) -> {error, Reason} end. +-spec get_by_email(worker(), email()) -> {ok, author()} | {error, author_not_found | term()}. get_by_email(Worker, Email) -> Sql = """ SELECT id, name, email @@ -80,6 +91,7 @@ get_by_email(Worker, Email) -> {error, Reason} end. +-spec delete(worker(), author_id()) -> ok | {error, author_not_found | term()}. delete(Worker, AuthorID) -> case is_uuid(AuthorID) of true -> @@ -88,6 +100,7 @@ delete(Worker, AuthorID) -> {error, author_not_found} end. +-spec delete_(worker(), author_id()) -> ok | {error, author_not_found | term()}. delete_(Worker, AuthorID) -> Sql = """ DELETE FROM author @@ -103,6 +116,8 @@ delete_(Worker, AuthorID) -> {error, Reason} end. +-spec list(worker(), pos_integer(), non_neg_integer()) -> + {ok, [author()]} | {error, term()}. list(Worker, Limit, Offset) -> Sql = """ SELECT id, name, email @@ -126,6 +141,7 @@ list(Worker, Limit, Offset) -> {error, Reason} end. +-spec search(worker(), binary(), pos_integer()) -> {ok, [author()]} | {error, term()}. search(Worker, SearchTerm, Limit) -> Sql = """ SELECT id, name, email FROM author @@ -152,11 +168,18 @@ search(Worker, SearchTerm, Limit) -> %% Internal functions -is_uuid(UUID) -> +-spec is_uuid(term()) -> boolean(). +is_uuid(<>) -> + try_string_to_uuid(UUID); +is_uuid(<>) -> + try_string_to_uuid(UUID); +is_uuid(_) -> + false. + +-spec try_string_to_uuid(uuid:uuid_string()) -> boolean(). +try_string_to_uuid(UUID) -> try uuid:string_to_uuid(UUID) of - _UUID -> - true + _ -> true catch - exit:badarg -> - false + exit:badarg -> false end. diff --git a/apps/dmt/src/dmt_author_handler.erl b/apps/dmt/src/dmt_author_handler.erl index fd2c549..a41c02e 100644 --- a/apps/dmt/src/dmt_author_handler.erl +++ b/apps/dmt/src/dmt_author_handler.erl @@ -2,17 +2,30 @@ -include_lib("damsel/include/dmsl_domain_conf_v2_thrift.hrl"). +-behaviour(woody_server_thrift_handler). + %% API -export([handle_function/4]). +-type options() :: dmt_api_woody_utils:handler_options(). + +-spec handle_function(woody:func(), woody:args(), woody_context:ctx(), options()) -> + {ok, woody:result()} | no_return(). handle_function(Function, Args, WoodyContext0, Options) -> DefaultDeadline = woody_deadline:from_timeout(default_handling_timeout(Options)), WoodyContext = dmt_api_woody_utils:ensure_woody_deadline_set(WoodyContext0, DefaultDeadline), - do_handle_function(Function, Args, WoodyContext, Options). + %% Cast: `woody:args()` is `tuple() | any()`; each `do_handle_function/4` + %% clause pattern-matches a specific arg tuple guaranteed by the thrift + %% schema. The shape is enforced by woody at deserialisation, not by + %% the type system, so we cross the trust boundary explicitly here. + do_handle_function(Function, eqwalizer:dynamic_cast(Args), WoodyContext, Options). +-spec default_handling_timeout(options()) -> timeout(). default_handling_timeout(#{default_handling_timeout := Timeout}) -> Timeout. +-spec do_handle_function(woody:func(), eqwalizer:dynamic(tuple()), woody_context:ctx(), options()) -> + {ok, woody:result()} | no_return(). %% Implement the Create function do_handle_function('Create', {Params}, _Context, _Options) -> #domain_conf_v2_AuthorParams{email = Email, name = Name} = Params, diff --git a/apps/dmt/src/dmt_database.erl b/apps/dmt/src/dmt_database.erl index e2b341a..29dc94b 100644 --- a/apps/dmt/src/dmt_database.erl +++ b/apps/dmt/src/dmt_database.erl @@ -26,6 +26,28 @@ -export([search_related_graph/8]). -export([parse_entity_validation_error/1]). +-type worker() :: atom() | epgsql:connection(). +-type version() :: integer(). +-type entity_id() :: binary(). +-type entity_type() :: atom() | binary(). +-type author_id() :: dmt_author:author_id(). +-type object_map() :: dmt_mapper:object_map(). +-type edge_map() :: #{ + source_ref := dmsl_domain_thrift:'Reference'(), + target_ref := dmsl_domain_thrift:'Reference'() +}. +-type sql_error() :: term(). + +-export_type([ + worker/0, + version/0, + entity_id/0, + entity_type/0, + sql_error/0, + edge_map/0 +]). + +-spec get_latest_version(worker()) -> {ok, version()} | {error, sql_error()}. get_latest_version(Worker) -> Query1 = """ @@ -40,6 +62,8 @@ get_latest_version(Worker) -> {error, Reason} end. +-spec get_object_latest_version(worker(), entity_id()) -> + {ok, version()} | {error, not_found | sql_error()}. get_object_latest_version(Worker, ChangedObjectId) -> Query0 = """ @@ -60,6 +84,7 @@ get_object_latest_version(Worker, ChangedObjectId) -> {error, Reason} end. +-spec get_new_version(worker(), author_id()) -> {ok, version()} | {error, sql_error()}. get_new_version(Worker, AuthorID) -> {ok, #{ git_ref := GitRef @@ -76,12 +101,13 @@ get_new_version(Worker, AuthorID) -> {error, Reason} end. +-spec clean_utf8_string(binary() | string()) -> binary(). clean_utf8_string(String) -> % Convert to binary if it's not already Binary = case is_binary(String) of true -> String; - false -> unicode:characters_to_binary(String) + false -> to_utf8_binary(unicode:characters_to_binary(String)) end, % Remove any invalid UTF-8 sequences case unicode:characters_to_binary(Binary, utf8, utf8) of @@ -90,11 +116,22 @@ clean_utf8_string(String) -> % and convert it to UTF-8. % binary_to_list(Binary) gives a list of bytes (0-255), % which are treated as Latin-1 codepoints. - unicode:characters_to_binary(binary_to_list(Binary), utf8); - CleanBinary -> - CleanBinary + to_utf8_binary(unicode:characters_to_binary(binary_to_list(Binary), utf8)); + CleanBinary when is_binary(CleanBinary) -> + CleanBinary; + {incomplete, Bin, _Rest} when is_binary(Bin) -> + Bin end. +-spec to_utf8_binary( + binary() | {error, binary(), term()} | {incomplete, binary(), binary()} +) -> binary(). +to_utf8_binary(Bin) when is_binary(Bin) -> Bin; +to_utf8_binary({error, Bin, _Rest}) when is_binary(Bin) -> Bin; +to_utf8_binary({incomplete, Bin, _Rest}) when is_binary(Bin) -> Bin. + +-spec insert_object(worker(), entity_id(), entity_type(), version(), binary(), binary() | string()) -> + ok | {error, sql_error()}. insert_object(Worker, ID1, Type, Version, Data1, SearchVector) -> Query = """ INSERT INTO entity @@ -113,6 +150,10 @@ insert_object(Worker, ID1, Type, Version, Data1, SearchVector) -> {error, Reason} end. +-spec update_object( + worker(), entity_id(), entity_type(), version(), binary(), binary() | string(), boolean() +) -> + ok | {error, sql_error()}. update_object( Worker, ID1, @@ -138,6 +179,13 @@ update_object( {error, Reason} end. +-spec insert_relations(worker(), entity_id(), entity_id(), version(), boolean()) -> + ok + | {error, + {duplicate_relation, binary()} + | {source_entity_not_found, dmsl_domain_thrift:'Reference'()} + | {target_entity_not_found, dmsl_domain_thrift:'Reference'()} + | sql_error()}. insert_relations(Worker, SourceID, TargetID, Version, IsActive) -> Query = """ INSERT INTO entity_relation @@ -173,8 +221,8 @@ insert_relations(Worker, SourceID, TargetID, Version, IsActive) -> %% @doc Parse validation error messages from the validate_entity_exists trigger %% Expected format: "ENTITY_NOT_EXISTS|SOURCE|entity_id" or "ENTITY_NOT_EXISTS|TARGET|entity_id" -spec parse_entity_validation_error(binary()) -> - {source_entity_not_found, binary()} - | {target_entity_not_found, binary()} + {source_entity_not_found, dmsl_domain_thrift:'Reference'()} + | {target_entity_not_found, dmsl_domain_thrift:'Reference'()} | unknown. parse_entity_validation_error(Message) -> case binary:split(Message, <<"|">>, [global]) of @@ -186,6 +234,8 @@ parse_entity_validation_error(Message) -> unknown end. +-spec get_references_to(worker(), entity_id(), version()) -> + [dmsl_domain_thrift:'Reference'()] | no_return(). get_references_to(Worker, ID, Version) -> Query = """ WITH LatestEdge AS ( @@ -211,11 +261,13 @@ get_references_to(Worker, ID, Version) -> Params = [Version, ID], case epg_pool:query(Worker, Query, Params) of {ok, _Columns, Refs} -> - lists:map(fun({Res}) -> dmt_mapper:string_to_ref(Res) end, Refs); + [dmt_mapper:string_to_ref(Res) || {Res} <- Refs, is_binary(Res)]; {error, Reason} -> - {error, Reason} + erlang:error({sql_error, Reason}) end. +-spec get_referenced_by(worker(), entity_id(), version()) -> + [dmsl_domain_thrift:'Reference'()] | no_return(). get_referenced_by(Worker, ID, Version) -> Query = """ WITH LatestEdge AS ( @@ -241,11 +293,13 @@ get_referenced_by(Worker, ID, Version) -> Params = [Version, ID], case epg_pool:query(Worker, Query, Params) of {ok, _Columns, Refs} -> - lists:map(fun({Res}) -> dmt_mapper:string_to_ref(Res) end, Refs); + [dmt_mapper:string_to_ref(Res) || {Res} <- Refs, is_binary(Res)]; {error, Reason} -> - {error, Reason} + erlang:error({sql_error, Reason}) end. +-spec get_next_sequence(worker(), entity_type()) -> + {ok, integer()} | {error, sequence_not_enabled | sql_error()}. get_next_sequence(Worker, Type) -> Query = """ UPDATE entity_type @@ -264,6 +318,7 @@ get_next_sequence(Worker, Type) -> {error, Reason} end. +-spec check_if_object_id_active(worker(), entity_id()) -> boolean() | {error, sql_error()}. check_if_object_id_active(Worker, ID0) -> Query = """ SELECT is_active @@ -281,6 +336,7 @@ check_if_object_id_active(Worker, ID0) -> {error, Reason} end. +-spec check_version_exists(worker(), version()) -> boolean() | {error, sql_error()}. check_version_exists(Worker, Version) -> Query = """ SELECT 1 @@ -297,6 +353,8 @@ check_version_exists(Worker, Version) -> {error, Reason} end. +-spec get_object(worker(), entity_id(), version()) -> + {ok, object_map()} | {error, not_found | sql_error()}. get_object(Worker, ID0, Version) -> Request = """ WITH LatestVersionAtRequestedTime AS ( @@ -338,6 +396,8 @@ get_object(Worker, ID0, Version) -> end end. +-spec get_objects(worker(), [entity_id()], version()) -> + {ok, [object_map()]} | {error, sql_error()}. get_objects(Worker, IDs, Version) -> Request = """ WITH LatestVersionAtRequestedTime AS ( @@ -374,6 +434,8 @@ get_objects(Worker, IDs, Version) -> {error, Reason} end. +-spec get_latest_object(worker(), entity_id()) -> + {ok, object_map()} | {error, not_found | sql_error()}. get_latest_object(Worker, ID0) -> Request = """ WITH LatestVersion AS ( @@ -409,6 +471,8 @@ get_latest_object(Worker, ID0) -> end end. +-spec get_version_creator(worker(), version()) -> + {ok, author_id()} | {error, not_found | sql_error()}. get_version_creator(Worker, Version) -> Request = """ SELECT created_by @@ -424,6 +488,13 @@ get_version_creator(Worker, Version) -> {ok, CreatedBy} end. +-spec get_version(worker(), version()) -> + {ok, #{ + version := version(), + created_at := dmt_mapper:pg_datetime(), + created_by := author_id() + }} + | {error, not_found | sql_error()}. get_version(Worker, Version) -> Request = """ SELECT version, created_at, created_by @@ -435,14 +506,34 @@ get_version(Worker, Version) -> case epg_pool:query(Worker, Request, [Version]) of {ok, _Columns, []} -> {error, not_found}; - {ok, _Columns, [{Version, CreatedAt, CreatedBy}]} -> + {ok, _Columns, [{Version, CreatedAt, CreatedBy}]} when is_binary(CreatedBy) -> {ok, #{ version => Version, - created_at => CreatedAt, + created_at => ensure_pg_datetime(CreatedAt), created_by => CreatedBy - }} + }}; + {error, Reason} -> + {error, Reason} end. +%% @doc Refine an opaque epgsql column value into `pg_datetime()`. Does no +%% conversion — just asserts the runtime shape and lets the type system see it. +-spec ensure_pg_datetime(term()) -> dmt_mapper:pg_datetime() | no_return(). +ensure_pg_datetime({{Y, Mo, D}, {H, Mi, S}} = T) when + is_integer(Y), + is_integer(Mo), + is_integer(D), + is_integer(H), + is_integer(Mi), + is_integer(S) orelse is_float(S) +-> + T; +ensure_pg_datetime(Other) -> + erlang:error({bad_pg_datetime, Other}). + +-spec get_object_history(worker(), entity_id(), pos_integer(), non_neg_integer()) -> + {ok, [object_map()], non_neg_integer() | undefined} + | {error, not_found | sql_error()}. get_object_history(Worker, Ref, Limit, Offset) -> Query = """ SELECT e.id, @@ -475,6 +566,7 @@ get_object_history(Worker, Ref, Limit, Offset) -> {error, Reason} end. +-spec has_more_object_history(worker(), entity_id(), non_neg_integer()) -> boolean(). has_more_object_history(Worker, Ref, Offset) -> Query = """ SELECT 1 @@ -495,6 +587,9 @@ has_more_object_history(Worker, Ref, Offset) -> false end. +-spec get_all_objects_history(worker(), pos_integer(), non_neg_integer()) -> + {ok, [object_map()], non_neg_integer() | undefined} + | {error, sql_error()}. get_all_objects_history(Worker, Limit, Offset) -> Query = """ SELECT e.id, @@ -524,6 +619,7 @@ get_all_objects_history(Worker, Limit, Offset) -> {error, Reason} end. +-spec has_more_all_objects_history(worker(), non_neg_integer()) -> boolean(). has_more_all_objects_history(Worker, Offset) -> % Сначала проверим, есть ли еще записи после текущего смещения + лимит Query = """ @@ -545,6 +641,10 @@ has_more_all_objects_history(Worker, Offset) -> false end. +-spec search_objects( + worker(), binary(), version(), entity_type() | undefined, pos_integer(), non_neg_integer() +) -> + {ok, {[object_map()], non_neg_integer() | undefined}}. search_objects(Worker, <<"*">>, Version, Type, Limit, Offset) -> % Use a pattern where the condition is always true when Type is NULL TypeValue = @@ -664,6 +764,10 @@ search_objects(Worker, Query, Version, Type, Limit, Offset) -> end. % Helper function to check if there are more search results +-spec has_more_search_results( + worker(), binary(), version(), entity_type() | undefined, non_neg_integer() +) -> + boolean(). has_more_search_results(Worker, <<"*">>, Version, TypeValue, Offset) -> CheckMoreQuery = """ WITH LatestVersionAtRequestedTime AS ( @@ -734,6 +838,8 @@ has_more_search_results(Worker, Query, Version, TypeValue, Offset) -> false end. +-spec check_entity_type_exists(worker(), entity_type()) -> + ok | {error, object_type_not_found}. check_entity_type_exists(Worker, Type) -> CheckMoreQuery = """ SELECT 1 FROM entity_type @@ -753,6 +859,8 @@ check_entity_type_exists(Worker, Type) -> {error, object_type_not_found} end. +-spec get_all_objects(worker(), version()) -> + {ok, [object_map()]} | {error, sql_error()}. get_all_objects(Worker, Version) -> Query = """ WITH LatestVersions AS ( @@ -787,6 +895,16 @@ get_all_objects(Worker, Version) -> {error, Reason} end. +-spec get_related_graph( + worker(), + entity_id(), + version(), + non_neg_integer() | undefined, + boolean() | undefined, + boolean() | undefined, + entity_type() | undefined +) -> + {ok, {[object_map()], [edge_map()]}} | {error, sql_error()}. get_related_graph( Worker, ObjectRef, Version, Depth, IncludeInbound, IncludeOutbound, TypeFilter ) -> @@ -800,6 +918,10 @@ get_related_graph( end. %% Helper function to get objects and apply type filter +-spec get_objects_and_filter( + worker(), [entity_id()], version(), entity_type() | undefined, [edge_map()], term() +) -> + {ok, {[object_map()], [edge_map()]}} | {error, sql_error()}. get_objects_and_filter(Worker, EntityIds, Version, TypeFilter, Edges, ObjectRef) -> case get_objects(Worker, EntityIds, Version) of {ok, AllNodes} -> @@ -813,6 +935,7 @@ get_objects_and_filter(Worker, EntityIds, Version, TypeFilter, Edges, ObjectRef) end. %% Filter nodes by type if type filter is specified +-spec filter_nodes_by_type([object_map()], entity_type() | undefined) -> [object_map()]. filter_nodes_by_type(Nodes, undefined) -> Nodes; filter_nodes_by_type(Nodes, FilterType) -> @@ -823,6 +946,7 @@ filter_nodes_by_type(Nodes, FilterType) -> end, [Node || Node <- Nodes, maps:get(type, Node) =:= FilterTypeBinary]. +-spec filter_edges_by_nodes([edge_map()], [object_map()]) -> [edge_map()]. filter_edges_by_nodes(Edges, Nodes) -> logger:error("filter_edges_by_nodes Edges: ~p, Nodes: ~p", [Edges, Nodes]), lists:filter( @@ -844,6 +968,15 @@ filter_edges_by_nodes(Edges, Nodes) -> Edges ). +-spec get_related_graph_edges( + worker(), + entity_id(), + version(), + non_neg_integer() | undefined, + boolean() | undefined, + boolean() | undefined +) -> + {ok, {[binary()], [edge_map()]}} | {error, sql_error()}. get_related_graph_edges(Worker, ObjectRef, Version, Depth, IncludeInbound, IncludeOutbound) -> Query = """ WITH RECURSIVE @@ -939,6 +1072,7 @@ get_related_graph_edges(Worker, ObjectRef, Version, Depth, IncludeInbound, Inclu {error, Reason} end. +-spec parse_graph_edges_result([{binary(), binary()}]) -> {[binary()], [edge_map()]}. parse_graph_edges_result(Rows) -> Edges = [ #{ @@ -955,6 +1089,16 @@ parse_graph_edges_result(Rows) -> {EntityIds1, Edges}. +-spec get_multiple_related_graph( + worker(), + [dmsl_domain_thrift:'Reference'()], + version(), + non_neg_integer() | undefined, + boolean() | undefined, + boolean() | undefined, + entity_type() | undefined +) -> + {ok, {[object_map()], [edge_map()]}} | {error, sql_error()}. get_multiple_related_graph( Worker, ObjectRefs, Version, Depth, IncludeInbound, IncludeOutbound, TypeFilter ) -> @@ -972,6 +1116,15 @@ get_multiple_related_graph( {error, Reason} end. +-spec get_multiple_related_graph_edges( + worker(), + [binary()], + version(), + non_neg_integer() | undefined, + boolean() | undefined, + boolean() | undefined +) -> + {ok, {[binary()], [edge_map()]}} | {error, sql_error()}. get_multiple_related_graph_edges(Worker, ObjectRefStrings, Version, Depth, IncludeInbound, IncludeOutbound) -> Query = """ WITH RECURSIVE @@ -1069,6 +1222,17 @@ get_multiple_related_graph_edges(Worker, ObjectRefStrings, Version, Depth, Inclu {error, Reason} end. +-spec search_related_graph( + worker(), + binary(), + binary() | undefined, + version(), + non_neg_integer() | undefined, + boolean() | undefined, + boolean() | undefined, + binary() | undefined +) -> + {ok, {[object_map()], [edge_map()]}} | {error, sql_error()}. search_related_graph( Worker, Query, SearchedType, Version, Depth, IncludeInbound, IncludeOutbound, ReturnedType ) -> diff --git a/apps/dmt/src/dmt_db_migration.erl b/apps/dmt/src/dmt_db_migration.erl index 0d2fe3a..f7b8dd9 100644 --- a/apps/dmt/src/dmt_db_migration.erl +++ b/apps/dmt/src/dmt_db_migration.erl @@ -157,7 +157,9 @@ with_connection(Args, Fun) -> {ok, Conn} -> Fun(Conn); {error, Error} -> - {error, io_lib:format("Failed to connect to database: ~p~n", [Args]), [Error]} + {error, lists:flatten(io_lib:format("Failed to connect to database: ~p~n", [Args])), [ + Error + ]} end. connection_opts(Args) -> @@ -173,40 +175,71 @@ connection_opts(_Args, {url, DatabaseUrl}) -> case uri_string:parse(string:trim(DatabaseUrl)) of {error, Error, Term} -> {error, {Error, Term}}; - Map = #{userinfo := UserPass, host := Host, path := Path} -> - {User, Pass} = - case string:split(UserPass, ":") of - [[]] -> {"postgres", ""}; - [U] -> {U, ""}; - [[], []] -> {"postgres", ""}; - [U, P] -> {U, P} - end, - + #{userinfo := UserPass, host := Host, path := Path} = Map -> + UserPassStr = to_string(UserPass), + PathStr = to_string(Path), + {User, Pass} = split_user_pass(UserPassStr), ConnectionOpts = #{ port => maps:get(port, Map, 5432), username => User, password => Pass, host => Host, - database => string:slice(Path, 1) + database => string:slice(PathStr, 1) }, + apply_query_opts(maps:get(query, Map, []), ConnectionOpts) + end. - case maps:get(query, Map, []) of - [] -> - {ok, ConnectionOpts}; - "?" ++ QueryString -> - case uri_string:dissect_query(QueryString) of - [] -> - {ok, ConnectionOpts}; - QueryList -> - case proplists:get_value("ssl", QueryList) of - undefined -> {ok, ConnectionOpts}; - [] -> {ok, maps:put(ssl, true, ConnectionOpts)}; - Value -> {ok, maps:put(ssl, list_to_atom(Value), ConnectionOpts)} - end - end - end +%% @doc Normalise chardata into a string, dropping any error/incomplete tails. +-spec to_string(unicode:chardata()) -> string(). +to_string(C) -> + case unicode:characters_to_list(C) of + L when is_list(L) -> L; + {error, L, _} when is_list(L) -> L; + {incomplete, L, _} when is_list(L) -> L end. +-spec split_user_pass(string()) -> {string(), string()}. +split_user_pass(UserPass) -> + case string:split(UserPass, ":") of + [[]] -> {"postgres", ""}; + [U] when is_list(U) -> {U, ""}; + [[], []] -> {"postgres", ""}; + [U, P] when is_list(U), is_list(P) -> {U, P}; + _ -> {"postgres", ""} + end. + +-spec apply_query_opts(term(), map()) -> {ok, map()}. +apply_query_opts([], ConnectionOpts) -> + {ok, ConnectionOpts}; +apply_query_opts([$? | QueryString], ConnectionOpts) when is_list(QueryString) -> + %% Cast: `uri_string:dissect_query/1` wants `uri_string:uri_string()` + %% (a specific `[char() | byte() | ...]` chardata variant). + %% `is_list/1` only narrows `term()` to `[term()]`; eqwalizer doesn't + %% refine list element types, but at runtime this is the tail of the + %% printable URL string we just parsed. + case uri_string:dissect_query(eqwalizer:dynamic_cast(QueryString)) of + [_ | _] = QueryList -> + apply_ssl_opt(proplists:get_value("ssl", QueryList), ConnectionOpts); + _ -> + {ok, ConnectionOpts} + end; +apply_query_opts(_, ConnectionOpts) -> + {ok, ConnectionOpts}. + +-spec apply_ssl_opt(term(), map()) -> {ok, map()}. +apply_ssl_opt(undefined, ConnectionOpts) -> + {ok, ConnectionOpts}; +apply_ssl_opt(true, ConnectionOpts) -> + {ok, maps:put(ssl, true, ConnectionOpts)}; +apply_ssl_opt(Value, ConnectionOpts) when is_list(Value) -> + %% Cast: `to_string/1` (and the underlying `unicode:characters_to_list/1`) + %% wants `unicode:chardata()`. `is_list/1` only proves `[term()]`; the + %% value comes from `uri_string:dissect_query/1` which produces chardata + %% at runtime. + {ok, maps:put(ssl, list_to_atom(to_string(eqwalizer:dynamic_cast(Value))), ConnectionOpts)}; +apply_ssl_opt(_, ConnectionOpts) -> + {ok, ConnectionOpts}. + -spec open_connection(list() | map()) -> {ok, epgsql:connection()} | {error, term()}. open_connection(Args) when is_list(Args) -> {ok, Opts} = connection_opts(Args), diff --git a/apps/dmt/src/dmt_json.erl b/apps/dmt/src/dmt_json.erl index b650930..1f337ec 100644 --- a/apps/dmt/src/dmt_json.erl +++ b/apps/dmt/src/dmt_json.erl @@ -12,7 +12,11 @@ decode(S) when is_binary(S) -> jsone:decode(S, [{keys, binary}, {object_format, proplist}]); decode(S) when is_list(S) -> - decode(unicode:characters_to_binary(S)). + case unicode:characters_to_binary(S) of + Bin when is_binary(Bin) -> decode(Bin); + {error, Bin, _} when is_binary(Bin) -> decode(Bin); + {incomplete, Bin, _} when is_binary(Bin) -> decode(Bin) + end. -spec encode(jsone:json_value()) -> binary(). encode(J) -> @@ -24,7 +28,7 @@ encode(J) -> -define(is_number(T), (?is_integer(T) orelse T == double)). -define(is_scalar(T), (?is_number(T) orelse T == string orelse element(1, T) == enum)). --spec json_to_term(jsone:json_value(), dmt_thrift:thrift_type()) -> term(). +-spec json_to_term(jsone:json_value(), dmt_thrift:thrift_type()) -> eqwalizer:dynamic(). json_to_term(Json, Type) -> json_to_term(Json, Type, []). diff --git a/apps/dmt/src/dmt_mapper.erl b/apps/dmt/src/dmt_mapper.erl index 5bb2cde..0284c41 100644 --- a/apps/dmt/src/dmt_mapper.erl +++ b/apps/dmt/src/dmt_mapper.erl @@ -14,9 +14,20 @@ -export([from_string/1]). -export([extract_searchable_text_from_term/1]). +%% Object map keyed by either atom (when produced from typed sources) or binary +%% (when produced from epgsql row column names). +-type object_map() :: #{atom() | binary() => term()}. +-type pg_datetime() :: calendar:datetime() | {calendar:date(), {0..23, 0..59, float()}}. + +-export_type([object_map/0, pg_datetime/0]). + +-spec to_marshalled_maps([epgsql:column()], [epgsql:equery_row()]) -> [object_map()]. to_marshalled_maps(Columns, Rows) -> to_marshalled_maps(Columns, Rows, fun marshall_object/1). +-spec to_marshalled_maps( + [epgsql:column()], [epgsql:equery_row()], fun((object_map()) -> Out) +) -> [Out]. to_marshalled_maps(Columns, Rows, TransformRowFun) -> ColNumbers = erlang:length(Columns), Seq = lists:seq(1, ColNumbers), @@ -36,19 +47,35 @@ to_marshalled_maps(Columns, Rows, TransformRowFun) -> ). %% for reference https://github.com/epgsql/epgsql#data-representation +-spec convert(epgsql:epgsql_type(), term()) -> term(). convert(timestamp, Value) -> - datetime_to_binary(Value); + convert_datetime(Value); convert(timestamptz, Value) -> - datetime_to_binary(Value); + convert_datetime(Value); convert(_Type, Value) -> Value. +-spec convert_datetime(term()) -> binary() | term(). +convert_datetime({{Y, Mo, D}, {H, Mi, S}} = Value) when + is_integer(Y), + is_integer(Mo), + is_integer(D), + is_integer(H), + is_integer(Mi), + is_integer(S) orelse is_float(S) +-> + datetime_to_binary(Value); +convert_datetime(Other) -> + Other. + +-spec datetime_to_binary(pg_datetime()) -> binary(). datetime_to_binary({Date, {Hour, Minute, Second}}) when is_float(Second) -> datetime_to_binary({Date, {Hour, Minute, trunc(Second)}}); -datetime_to_binary(DateTime) -> +datetime_to_binary({_Date, {_H, _M, S}} = DateTime) when is_integer(S) -> UnixTime = genlib_time:daytime_to_unixtime(DateTime), genlib_rfc3339:format(UnixTime, second). +-spec marshall_object(object_map()) -> dmt_object:object(). marshall_object(#{ <<"id">> := ID, <<"entity_type">> := Type, @@ -56,7 +83,11 @@ marshall_object(#{ <<"data">> := Data, <<"created_at">> := CreatedAt, <<"is_active">> := IsActive -}) -> +}) when + is_binary(ID), + is_binary(Data), + is_boolean(IsActive) +-> dmt_object:just_object( string_to_ref(ID), Type, @@ -69,28 +100,43 @@ marshall_object(#{ -define(REF_TYPE, {struct, union, {dmsl_domain_thrift, 'Reference'}}). -define(OBJECT_TYPE, {struct, union, {dmsl_domain_thrift, 'DomainObject'}}). +-spec ref_to_string(dmsl_domain_thrift:'Reference'()) -> binary(). ref_to_string({_Type, _} = Ref) -> thrift_term_to_string_(Ref, ?REF_TYPE). +%% Returns the decoded thrift Reference. The string is assumed to be the +%% serialised form produced by `ref_to_string/1` — its shape is enforced by the +%% JSON-to-thrift round-trip rather than the type system, hence the dynamic +%% return type. +-spec string_to_ref(binary() | string()) -> eqwalizer:dynamic(dmsl_domain_thrift:'Reference'()). string_to_ref(Str) -> string_to_thrift_term_(Str, ?REF_TYPE). +-spec object_to_string(dmsl_domain_thrift:'DomainObject'()) -> binary(). object_to_string({_Type, _} = Data) -> thrift_term_to_string_(Data, ?OBJECT_TYPE). +-spec string_to_object(binary() | string()) -> + eqwalizer:dynamic(dmsl_domain_thrift:'DomainObject'()). string_to_object(Str) -> string_to_thrift_term_(Str, ?OBJECT_TYPE). +-spec thrift_term_to_string_(term(), dmt_thrift:thrift_type()) -> binary(). thrift_term_to_string_(Term, ThriftType) -> dmt_json:encode(dmt_json:term_to_json(Term, ThriftType)). +-spec string_to_thrift_term_(binary() | string(), dmt_thrift:thrift_type()) -> eqwalizer:dynamic(). string_to_thrift_term_(Str, ThriftType) -> dmt_json:json_to_term(dmt_json:decode(Str), ThriftType). +-spec to_string(term()) -> binary(). to_string(A0) -> A1 = term_to_binary(A0), base64:encode(A1). +%% Returns the decoded term — its concrete shape depends on what was passed to +%% `to_string/1` originally. Callers must guard the value before use. +-spec from_string(binary()) -> term(). from_string(B0) -> B1 = base64:decode(B0), binary_to_term(B1). diff --git a/apps/dmt/src/dmt_repository.erl b/apps/dmt/src/dmt_repository.erl index 4fa58ce..82daa6d 100644 --- a/apps/dmt/src/dmt_repository.erl +++ b/apps/dmt/src/dmt_repository.erl @@ -19,8 +19,29 @@ -export([get_multiple_related_graph/1]). -export([search_related_graph/1]). +-type worker() :: dmt_database:worker(). +-type version() :: dmt_database:version(). +-type version_ref() :: {version, version()} | {head, dmsl_domain_conf_v2_thrift:'Head'()}. +-type object_ref() :: dmsl_domain_thrift:'Reference'(). +-type author_id() :: dmt_author:author_id(). +-type operation() :: dmsl_domain_conf_v2_thrift:'Operation'(). +-type object_map() :: dmt_mapper:object_map(). + +-type get_error() :: version_not_found | {object_not_found, object_ref()} | term(). + +-type commit_error() :: + {operation_error, {conflict, term()} | {invalid, term()}} + | version_not_found + | author_not_found + | migration_in_progress + | {object_update_too_old, {object_ref(), version()}} + | {conflict, binary()} + | eqwalizer:dynamic(). + %% +-spec get_object(worker(), version_ref(), object_ref()) -> + {ok, dmsl_domain_conf_v2_thrift:'VersionedObject'()} | {error, get_error()}. get_object(Worker, {version, V}, ObjectRef) -> case get_target_object(Worker, ObjectRef, V) of {ok, #{data := Data} = Object} -> @@ -51,6 +72,8 @@ get_object(Worker, {head, #domain_conf_v2_Head{}}, ObjectRef) -> {error, Reason} end. +-spec get_object_with_references(worker(), version_ref(), object_ref()) -> + {ok, dmsl_domain_conf_v2_thrift:'VersionedObjectWithReferences'()} | {error, get_error()}. get_object_with_references(Worker, {version, V}, ObjectRef) -> case get_target_object(Worker, ObjectRef, V) of {ok, #{data := Data} = Object} -> @@ -81,6 +104,8 @@ get_object_with_references(Worker, {head, #domain_conf_v2_Head{}}, ObjectRef) -> {ok, Version} = dmt_database:get_latest_version(Worker), get_object_with_references(Worker, {version, Version}, ObjectRef). +-spec get_objects(worker(), version_ref(), [object_ref()]) -> + {ok, [dmsl_domain_conf_v2_thrift:'VersionedObject'()]} | {error, version_not_found | term()}. get_objects(Worker, {version, V}, ObjectRefs) -> case dmt_database:check_version_exists(Worker, V) of true -> @@ -116,6 +141,8 @@ get_objects(Worker, {head, #domain_conf_v2_Head{}}, ObjectRefs) -> {error, Reason} end. +-spec get_snapshot(worker(), version_ref()) -> + {ok, dmsl_domain_conf_v2_thrift:'Snapshot'()} | {error, version_not_found | term()}. get_snapshot(Worker, {head, #domain_conf_v2_Head{}}) -> case dmt_database:get_latest_version(Worker) of {ok, LatestVersion} -> @@ -133,10 +160,9 @@ get_snapshot(Worker, {version, Version}) -> created_by := AuthorID }} = dmt_database:get_version(Worker, Version), {ok, Author} = dmt_author:get(AuthorID), - Domain = #{K => V || #{id := K, data := V} <- Objects}, {ok, #domain_conf_v2_Snapshot{ version = Version, - domain = Domain, + domain = to_domain(Objects), created_at = dmt_mapper:datetime_to_binary(CreatedAt), changed_by = Author }}; @@ -147,6 +173,9 @@ get_snapshot(Worker, {version, Version}) -> {error, version_not_found} end. +-spec get_related_graph(dmsl_domain_conf_v2_thrift:'RelatedGraphRequest'()) -> + {ok, dmsl_domain_conf_v2_thrift:'RelatedGraph'()} + | {error, object_not_found | version_not_found | term()}. get_related_graph(Request) -> #domain_conf_v2_RelatedGraphRequest{ ref = ObjectRef, @@ -207,18 +236,28 @@ get_related_graph(Request) -> {error, Reason} end. +%% @doc Build a thrift `Domain` map from a list of stored objects. Each row's +%% `id` becomes the map key and `data` becomes the value; rows whose shape +%% isn't a proper `{tag, _}` tagged tuple are skipped. The result is presented +%% as `dynamic()` because eqwalizer can't refine `{atom(), _}` to the precise +%% `Reference()` / `DomainObject()` tagged-union shapes. +-spec to_domain([object_map()]) -> eqwalizer:dynamic(). +to_domain(Objects) -> + maps:from_list( + [{K, V} || #{id := {KT, _} = K, data := {VT, _} = V} <- Objects, is_atom(KT), is_atom(VT)] + ). + +-spec sort_objects_by_ids([object_map()], [term()]) -> [object_map()]. sort_objects_by_ids(Objects, IDs) -> % Create a map of ID -> Object for easier lookup ObjectsMap = maps:from_list([{maps:get(id, Obj), Obj} || Obj <- Objects]), % Use list comprehension to order objects according to input IDs % Skip IDs that don't have corresponding objects - [ - maps:get(ID, ObjectsMap, undefined) - || ID <- IDs, - maps:is_key(ID, ObjectsMap) - ]. + [Obj || ID <- IDs, {ok, Obj} <- [maps:find(ID, ObjectsMap)]]. +-spec get_object_history(object_ref(), dmsl_domain_conf_v2_thrift:'RequestParams'()) -> + {ok, dmsl_domain_conf_v2_thrift:'ObjectVersionsResponse'()} | {error, not_found | term()}. get_object_history(ObjectRef, RequestParams) -> #domain_conf_v2_RequestParams{ limit = Limit, @@ -252,9 +291,12 @@ get_object_history(ObjectRef, RequestParams) -> end. % Done this way to keep hierarchy of calls +-spec get_latest_version() -> {ok, version()} | {error, term()}. get_latest_version() -> dmt_database:get_latest_version(default_pool). +-spec get_all_objects_history(dmsl_domain_conf_v2_thrift:'RequestParams'()) -> + {ok, dmsl_domain_conf_v2_thrift:'ObjectVersionsResponse'()} | {error, term()}. get_all_objects_history(Request) -> #domain_conf_v2_RequestParams{ limit = Limit, @@ -284,19 +326,47 @@ get_all_objects_history(Request) -> {error, Reason} end. +-spec maybe_to_string(term(), Default) -> binary() | Default. maybe_to_string(undefined, Default) -> Default; maybe_to_string(Value, _) -> dmt_mapper:to_string(Value). -maybe_from_string(undefined, Default) -> Default; -maybe_from_string(Value, _) -> dmt_mapper:from_string(Value). +-spec maybe_from_string(binary() | undefined, Default) -> integer() | Default | no_return(). +maybe_from_string(undefined, Default) -> + Default; +maybe_from_string(Value, _) -> + case dmt_mapper:from_string(Value) of + N when is_integer(N) -> N; + Other -> erlang:error({bad_continuation_token, Other}) + end. +-spec marshall_to_object_info(object_map()) -> dmsl_domain_conf_v2_thrift:'VersionedObjectInfo'(). marshall_to_object_info(Object) -> #domain_conf_v2_VersionedObjectInfo{ - version = maps:get(version, Object), - changed_at = maps:get(created_at, Object), - changed_by = maps:get(created_by, Object) + version = get_version(Object), + changed_at = get_timestamp(created_at, Object), + changed_by = get_author(created_by, Object) }. +-spec get_version(object_map()) -> dmsl_domain_conf_v2_thrift:'Version'() | no_return(). +get_version(#{version := V}) when is_integer(V) -> V; +get_version(Object) -> erlang:error({bad_version, Object}). + +-spec get_timestamp(atom(), object_map()) -> dmsl_base_thrift:'Timestamp'() | no_return(). +get_timestamp(Key, Object) -> + case maps:get(Key, Object, undefined) of + B when is_binary(B) -> B; + Other -> erlang:error({bad_timestamp, Key, Other}) + end. + +-spec get_author(atom(), object_map()) -> dmsl_domain_conf_v2_thrift:'Author'() | no_return(). +get_author(Key, Object) -> + case maps:get(Key, Object, undefined) of + #domain_conf_v2_Author{} = A -> A; + Other -> erlang:error({bad_author, Key, Other}) + end. + +-spec search_objects(dmsl_domain_conf_v2_thrift:'SearchRequestParams'()) -> + {ok, dmsl_domain_conf_v2_thrift:'SearchResponse'()} | {error, object_type_not_found | term()}. search_objects(Request) -> #domain_conf_v2_SearchRequestParams{ query = Query, @@ -340,6 +410,9 @@ search_objects(Request) -> {error, Reason} end. +-spec search_full_objects(dmsl_domain_conf_v2_thrift:'SearchRequestParams'()) -> + {ok, dmsl_domain_conf_v2_thrift:'SearchFullResponse'()} + | {error, object_type_not_found | term()}. search_full_objects(Request) -> #domain_conf_v2_SearchRequestParams{ query = Query, @@ -381,6 +454,7 @@ search_full_objects(Request) -> {error, Reason} end. +-spec filter_search_results([object_map()]) -> [object_map()]. filter_search_results(Objects) -> lists:filter( fun(Object) -> @@ -403,6 +477,8 @@ filter_search_results(Objects) -> Objects ). +-spec maybe_check_entity_type_exists(atom() | binary() | undefined) -> + ok | {error, object_type_not_found | term()}. maybe_check_entity_type_exists(undefined) -> ok; maybe_check_entity_type_exists(Type) -> dmt_database:check_entity_type_exists(default_pool, Type). @@ -522,6 +598,8 @@ commit_relations_changes(Worker, NewVersion, RelationsChanges) -> RelationsChanges ). +-spec commit(version(), [operation()], author_id()) -> + {ok, version(), [term()]} | {error, commit_error()}. commit(Version, Operations, AuthorID) -> Result = epg_pool:transaction( default_pool, @@ -550,7 +628,7 @@ commit(Version, Operations, AuthorID) -> end ), case Result of - {ok, ResVersion, NewObjects, AuthorID} -> + {ok, ResVersion, NewObjects, AuthorID} when is_integer(ResVersion), is_list(NewObjects) -> {ok, ResVersion, NewObjects}; {error, {error, error, _, conflict_detected, Msg, _}} -> {error, {conflict, Msg}}; @@ -561,7 +639,11 @@ commit(Version, Operations, AuthorID) -> {error, {invalid, _} = Error} -> {error, {operation_error, Error}}; {error, Error} -> - {error, Error} + %% Cast: catch-all for unrecognised errors from `epg_pool:transaction/2`. + %% `Error` is `term()` here; `commit_error()` enumerates the structured + %% alternatives we map. The handler maps the known atoms / tuples and + %% anything else propagates as-is for a generic internal-error response. + {error, eqwalizer:dynamic_cast(Error)} end. validate_no_references_to_entities(Worker, RemovedObjectsReferences, Version) -> @@ -644,11 +726,17 @@ give_data_id({Tag, Data}, Ref) -> end, DomainObjects ), + finish_give_data_id(ObjectName, Tag, Data, Ref). + +-spec finish_give_data_id(term(), atom(), term(), term()) -> {atom(), tuple()}. +finish_give_data_id(ObjectName0, Tag, Data, Ref) when is_atom(ObjectName0) -> + %% Cast: `dmsl_domain_thrift:struct_info/1` and `:record_name/1` accept + %% a specific atom union (`struct_name() | exception_name()`). `ObjectName0` + %% was pulled out of a runtime schema tuple, so the type system can only + %% see `atom()` — wider than the union the callees declare. + ObjectName = eqwalizer:dynamic_cast(ObjectName0), RecordName = dmsl_domain_thrift:record_name(ObjectName), - {_, _, [ - FirstField, - SecondField - ]} = dmsl_domain_thrift:struct_info(ObjectName), + {_, _, [FirstField, SecondField]} = dmsl_domain_thrift:struct_info(ObjectName), First = get_object_field(FirstField, Data, Ref), Second = get_object_field(SecondField, Data, Ref), {Tag, {RecordName, First, Second}}. @@ -728,7 +816,10 @@ get_unique_numerical_id(Worker, Type) -> end. get_unique_uuid(Worker, Type) -> - NewUUID = uuid:uuid_to_string(uuid:get_v4_urandom(), binary_standard), + NewUUID = + case uuid:uuid_to_string(uuid:get_v4_urandom(), binary_standard) of + B when is_binary(B) -> B + end, NewID = dmt_object_id:get_uuid_object_id(Type, NewUUID), NewRefString = dmt_mapper:ref_to_string({Type, NewID}), case dmt_database:check_if_object_id_active(Worker, NewRefString) of @@ -853,6 +944,9 @@ validate_object_exists(Worker, ObjectRef, Version) -> {error, not_found} -> {error, object_not_found} end. +-spec get_multiple_related_graph(dmsl_domain_conf_v2_thrift:'MultipleRelatedGraphRequest'()) -> + {ok, dmsl_domain_conf_v2_thrift:'RelatedGraph'()} + | {error, object_not_found | version_not_found | term()}. get_multiple_related_graph(Request) -> #domain_conf_v2_MultipleRelatedGraphRequest{ refs = ObjectRefs, @@ -910,6 +1004,9 @@ get_multiple_related_graph(Request) -> {error, Reason} end. +-spec search_related_graph(dmsl_domain_conf_v2_thrift:'SearchRelatedGraphRequest'()) -> + {ok, dmsl_domain_conf_v2_thrift:'RelatedGraph'()} + | {error, object_type_not_found | version_not_found | term()}. search_related_graph(Request) -> #domain_conf_v2_SearchRelatedGraphRequest{ query = Query, diff --git a/apps/dmt/src/dmt_repository_client_handler.erl b/apps/dmt/src/dmt_repository_client_handler.erl index e5f4ece..3df79f8 100644 --- a/apps/dmt/src/dmt_repository_client_handler.erl +++ b/apps/dmt/src/dmt_repository_client_handler.erl @@ -2,18 +2,31 @@ -include_lib("damsel/include/dmsl_domain_conf_v2_thrift.hrl"). +-behaviour(woody_server_thrift_handler). + -define(EPGPOOL, default_pool). -export([handle_function/4]). +-type options() :: dmt_api_woody_utils:handler_options(). + +-spec handle_function(woody:func(), woody:args(), woody_context:ctx(), options()) -> + {ok, woody:result()} | no_return(). handle_function(Function, Args, WoodyContext0, Options) -> DefaultDeadline = woody_deadline:from_timeout(default_handling_timeout(Options)), WoodyContext = dmt_api_woody_utils:ensure_woody_deadline_set(WoodyContext0, DefaultDeadline), - do_handle_function(Function, Args, WoodyContext, Options). + %% Cast: `woody:args()` is `tuple() | any()`; each `do_handle_function/4` + %% clause pattern-matches a specific arg tuple guaranteed by the thrift + %% schema. The shape is enforced by woody at deserialisation, not by + %% the type system, so we cross the trust boundary explicitly here. + do_handle_function(Function, eqwalizer:dynamic_cast(Args), WoodyContext, Options). +-spec default_handling_timeout(options()) -> timeout(). default_handling_timeout(#{default_handling_timeout := Timeout}) -> Timeout. +-spec do_handle_function(woody:func(), eqwalizer:dynamic(tuple()), woody_context:ctx(), options()) -> + {ok, woody:result()} | no_return(). do_handle_function('CheckoutObject', {VersionRef, ObjectRef}, _Context, _Options) -> %% Fetch the object based on VersionReference and Reference case dmt_repository:get_object(?EPGPOOL, VersionRef, ObjectRef) of diff --git a/apps/dmt/src/dmt_repository_handler.erl b/apps/dmt/src/dmt_repository_handler.erl index 91e9bae..c6c0a82 100644 --- a/apps/dmt/src/dmt_repository_handler.erl +++ b/apps/dmt/src/dmt_repository_handler.erl @@ -2,20 +2,32 @@ -include_lib("damsel/include/dmsl_domain_conf_v2_thrift.hrl"). +-behaviour(woody_server_thrift_handler). + %% API -export([handle_function/4]). +-type options() :: dmt_api_woody_utils:handler_options(). + +-spec handle_function(woody:func(), woody:args(), woody_context:ctx(), options()) -> + {ok, woody:result()} | no_return(). handle_function(Function, Args, WoodyContext0, Options) -> DefaultDeadline = woody_deadline:from_timeout(default_handling_timeout(Options)), WoodyContext = dmt_api_woody_utils:ensure_woody_deadline_set(WoodyContext0, DefaultDeadline), - do_handle_function(Function, Args, WoodyContext, Options). + %% Cast: `woody:args()` is `tuple() | any()`; each `do_handle_function/4` + %% clause pattern-matches a specific arg tuple guaranteed by the thrift + %% schema. The shape is enforced by woody at deserialisation, not by + %% the type system, so we cross the trust boundary explicitly here. + do_handle_function(Function, eqwalizer:dynamic_cast(Args), WoodyContext, Options). +-spec do_handle_function(woody:func(), eqwalizer:dynamic(tuple()), woody_context:ctx(), options()) -> + {ok, woody:result()} | no_return(). do_handle_function('Commit', {Version, Operations, AuthorID}, _Context, _Options) -> case dmt_repository:commit(Version, Operations, AuthorID) of {ok, NextVersion, NewObjects} -> {ok, #domain_conf_v2_CommitResponse{ version = NextVersion, - new_objects = ordsets:from_list(NewObjects) + new_objects = ordsets:from_list(filter_domain_objects(NewObjects)) }}; {error, {operation_error, Error}} -> woody_error:raise(business, handle_operation_error(Error)); @@ -109,9 +121,13 @@ do_handle_function('SearchRelatedGraph', {SearchRelatedGraphRequest}, _Context, woody_error:raise(system, {internal, Reason}) end. +-spec default_handling_timeout(options()) -> timeout(). default_handling_timeout(#{default_handling_timeout := Timeout}) -> Timeout. +-spec handle_operation_error({conflict, term()} | {invalid, term()}) -> + dmsl_domain_conf_v2_thrift:'OperationConflict'() + | dmsl_domain_conf_v2_thrift:'OperationInvalid'(). handle_operation_error({conflict, Conflict}) -> #domain_conf_v2_OperationConflict{ conflict = handle_operation_conflict(Conflict) @@ -121,27 +137,53 @@ handle_operation_error({invalid, Invalid}) -> errors = handle_operation_invalid(Invalid) }. +-spec handle_operation_conflict(term()) -> dmsl_domain_conf_v2_thrift:'Conflict'() | no_return(). handle_operation_conflict({object_already_exists, Ref}) -> - {object_already_exists, #domain_conf_v2_ObjectAlreadyExistsConflict{object_ref = Ref}}; + {object_already_exists, #domain_conf_v2_ObjectAlreadyExistsConflict{object_ref = to_ref(Ref)}}; handle_operation_conflict({forced_id_exists, Ref}) -> - {object_already_exists, #domain_conf_v2_ObjectAlreadyExistsConflict{object_ref = Ref}}; + {object_already_exists, #domain_conf_v2_ObjectAlreadyExistsConflict{object_ref = to_ref(Ref)}}; handle_operation_conflict({object_not_found, Ref}) -> - {object_not_found, #domain_conf_v2_ObjectNotFoundConflict{object_ref = Ref}}; + {object_not_found, #domain_conf_v2_ObjectNotFoundConflict{object_ref = to_ref(Ref)}}; handle_operation_conflict({object_reference_mismatch, Ref}) -> - {object_reference_mismatch, #domain_conf_v2_ObjectReferenceMismatchConflict{object_ref = Ref}}; + {object_reference_mismatch, #domain_conf_v2_ObjectReferenceMismatchConflict{ + object_ref = to_ref(Ref) + }}; handle_operation_conflict({object_needs_reference, Object}) -> - {object_needs_reference, #domain_conf_v2_ObjectNeedsReference{object = Object}}. + {object_needs_reference, #domain_conf_v2_ObjectNeedsReference{ + object = to_refless_object(Object) + }}. -handle_operation_invalid({objects_not_exist, Refs}) -> +-spec handle_operation_invalid(term()) -> [dmsl_domain_conf_v2_thrift:'OperationError'()] | no_return(). +handle_operation_invalid({objects_not_exist, Refs}) when is_list(Refs) -> [ {object_not_exists, #domain_conf_v2_NonexistantObject{ - object_ref = Ref, - referenced_by = ReferencedBy + object_ref = to_ref(Ref), + referenced_by = to_ref_list(ReferencedBy) }} || {Ref, ReferencedBy} <- Refs ]; -handle_operation_invalid({object_reference_cycles, Cycles}) -> +handle_operation_invalid({object_reference_cycles, Cycles}) when is_list(Cycles) -> [ - {object_reference_cycle, #domain_conf_v2_ObjectReferenceCycle{cycle = Cycle}} + {object_reference_cycle, #domain_conf_v2_ObjectReferenceCycle{cycle = to_ref_list(Cycle)}} || Cycle <- Cycles ]. + +%% @doc Narrow a `term()` to a `Reference` or crash with a useful diagnostic. +%% Eqwalizer can't refine `{atom(), _}` to a thrift tagged-union, hence the +%% `dynamic()` return type — the guard enforces the shape at runtime. +-spec to_ref(term()) -> eqwalizer:dynamic() | no_return(). +to_ref({Tag, _} = Ref) when is_atom(Tag) -> Ref; +to_ref(Other) -> erlang:error({bad_reference, Other}). + +-spec to_ref_list(term()) -> [eqwalizer:dynamic()] | no_return(). +to_ref_list(L) when is_list(L) -> [to_ref(R) || R <- L]; +to_ref_list(Other) -> erlang:error({bad_reference_list, Other}). + +-spec to_refless_object(term()) -> eqwalizer:dynamic() | no_return(). +to_refless_object({Tag, _} = Obj) when is_atom(Tag) -> Obj; +to_refless_object(Other) -> erlang:error({bad_refless_object, Other}). + +%% @doc Restrict a list of new objects to tagged-tuple shapes. +-spec filter_domain_objects([term()]) -> [eqwalizer:dynamic()]. +filter_domain_objects(L) -> + [Obj || {Tag, _} = Obj <- L, is_atom(Tag)]. diff --git a/apps/dmt/src/dmt_sup.erl b/apps/dmt/src/dmt_sup.erl index 1d1b6e3..ee4bfee 100644 --- a/apps/dmt/src/dmt_sup.erl +++ b/apps/dmt/src/dmt_sup.erl @@ -17,6 +17,7 @@ -define(APP, dmt). -define(DEFAULT_DB, default_db). +-spec start_link() -> supervisor:startlink_ret(). start_link() -> supervisor:start_link({local, ?APP}, ?MODULE, []). @@ -29,6 +30,7 @@ start_link() -> %% shutdown => shutdown(), % optional %% type => worker(), % optional %% modules => modules()} % optional +-spec init(term()) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}. init(_) -> ok = dbinit(), ok = setup_kafka(dmt_kafka_publisher:is_kafka_enabled()), @@ -60,6 +62,7 @@ init(_) -> {ok, {SupFlags, ChildSpecs}}. +-spec dbinit() -> ok | no_return(). dbinit() -> WorkDir = get_env_var("WORK_DIR"), _ = set_database_url(), @@ -75,17 +78,28 @@ dbinit() -> throw({migrations_error, Reason}) end. +-type db_conn_opts() :: #{ + host := list(), + port := non_neg_integer(), + username := list(), + password := list(), + database := list() +}. + +-spec set_database_url() -> true. set_database_url() -> EpgDbName = application_get_env(?APP, epg_db_name, ?DEFAULT_DB), - #{ - EpgDbName := #{ - host := PgHost, - port := PgPort, - username := PgUser, - password := PgPassword, - database := DbName - } - } = application_get_env(epg_connector, databases), + Databases = application_get_env(epg_connector, databases), + set_database_url_(get_db_opts(EpgDbName, Databases)). + +-spec set_database_url_(db_conn_opts()) -> true. +set_database_url_(#{ + host := PgHost, + port := PgPort, + username := PgUser, + password := PgPassword, + database := DbName +}) -> %% DATABASE_URL=postgresql://postgres:postgres@db/dmtv2 PgPortStr = erlang:integer_to_list(PgPort), Value = @@ -93,14 +107,45 @@ set_database_url() -> DbName, true = os:putenv("DATABASE_URL", Value). +-spec get_db_opts(term(), term()) -> db_conn_opts(). +get_db_opts(EpgDbName, Databases) when is_map(Databases) -> + case maps:get(EpgDbName, Databases, undefined) of + #{ + host := Host, + port := Port, + username := User, + password := Pass, + database := DbName + } when + is_list(Host), + is_integer(Port), + is_list(User), + is_list(Pass), + is_list(DbName) + -> + #{ + host => Host, + port => Port, + username => User, + password => Pass, + database => DbName + }; + Other -> + erlang:error({bad_db_config, EpgDbName, Other}) + end; +get_db_opts(_EpgDbName, Other) -> + erlang:error({bad_epg_connector_databases, Other}). + %% internal functions +-spec get_env_var(string()) -> string() | no_return(). get_env_var(Name) -> case os:getenv(Name) of false -> throw({os_env_required, Name}); V -> V end. +-spec get_repository_handlers() -> [woody:http_handler(woody:th_handler())]. get_repository_handlers() -> DefaultTimeout = application_get_env(?APP, default_woody_handling_timeout, timer:seconds(30)), [ @@ -136,6 +181,7 @@ get_handler(author, Options) -> {dmt_author_handler, Options} }}. +-spec get_service(repository | repository_client | author) -> woody:service(). get_service(repository) -> {dmsl_domain_conf_v2_thrift, 'Repository'}; get_service(repository_client) -> @@ -143,7 +189,7 @@ get_service(repository_client) -> get_service(author) -> {dmsl_domain_conf_v2_thrift, 'AuthorManagement'}. --spec enable_health_logging(erl_health:check()) -> erl_health:check(). +-spec enable_health_logging(map()) -> map(). enable_health_logging(Check) -> EvHandler = {erl_health_event_handler, []}, maps:map(fun(_, {_, _, _} = V) -> #{runner => V, event_handler => EvHandler} end, Check). @@ -153,6 +199,7 @@ get_prometheus_route() -> {"/metrics/[:registry]", prometheus_cowboy2_handler, []}. %% @doc Setup damsel version information from multiple sources +-spec setup_damsel_version() -> ok. setup_damsel_version() -> DamselVersionInfo = get_damsel_version(), logger:warning("Damsel version info: ~p", [DamselVersionInfo]), @@ -210,29 +257,48 @@ get_damsel_git_ref_from_lock() -> extract_damsel_ref_from_lock_data({_LockVersion, Deps}) when is_list(Deps) -> case lists:keyfind(<<"damsel">>, 1, Deps) of {<<"damsel">>, {git, _Url, {ref, Ref}}, _Level} -> - {ok, Ref}; + ensure_string(Ref); _ -> error end; extract_damsel_ref_from_lock_data(_) -> error. +-spec ensure_string(term()) -> {ok, string()} | error. +ensure_string(S) when is_list(S) -> + case io_lib:printable_list(S) of + %% Cast: `is_list/1` narrows `term()` to `[term()]`, and + %% `io_lib:printable_list/1` is a runtime predicate eqwalizer can't use + %% to refine the element type further. We've just confirmed it's a + %% printable string at runtime so cast to `string() = [char()]`. + true -> {ok, eqwalizer:dynamic_cast(S)}; + false -> error + end; +ensure_string(_) -> + error. + +%% @doc Read an `application:get_env/2` value. The runtime contract is dynamic +%% because sys.config is opaque to the type system, so callers narrow with +%% guards before use. +-spec application_get_env(atom(), atom()) -> eqwalizer:dynamic(). application_get_env(App, Key) -> application_get_env(App, Key, undefined). +-spec application_get_env(atom(), atom(), Default) -> eqwalizer:dynamic() | Default. application_get_env(App, Key, Default) -> case application:get_env(App, Key) of {ok, Value} -> Value; undefined -> Default end. +-spec setup_kafka(boolean()) -> ok | no_return(). setup_kafka(false) -> ok; setup_kafka(_) -> ClientName = dmt_kafka_client, - Clients = application_get_env(brod, clients, []), - Client = proplists:get_value(ClientName, Clients), - Endpoints = proplists:get_value(endpoints, Client), + Clients = ensure_list(application_get_env(brod, clients, [])), + Client = ensure_list(proplists:get_value(ClientName, Clients)), + Endpoints = ensure_list(proplists:get_value(endpoints, Client)), ClientConfig = proplists:delete(endpoints, Client), _ = logger:info("Starting Kafka client ~p with endpoints ~p and config ~p", [ @@ -248,3 +314,8 @@ setup_kafka(_) -> logger:error("Failed to start Kafka client ~p: ~p", [ClientName, Reason]), throw({kafka_client_start_error, Reason}) end. + +%% @doc Narrow a `term()` to a list (defaulting to []). +-spec ensure_list(term()) -> list(). +ensure_list(L) when is_list(L) -> L; +ensure_list(_) -> []. diff --git a/apps/dmt/src/dmt_thrift.erl b/apps/dmt/src/dmt_thrift.erl index 68eb2ca..7e04bec 100644 --- a/apps/dmt/src/dmt_thrift.erl +++ b/apps/dmt/src/dmt_thrift.erl @@ -6,17 +6,25 @@ -export_type([thrift_type/0]). -export_type([function_schema/0]). -export_type([thrift_value/0]). +-export_type([struct_info/0]). +-export_type([field_info/0]). +-export_type([struct_flavour/0]). +-export_type([type_ref/0]). -type thrift_value() :: term(). +%% A thrift field type. Recursive: structs may carry their own inline schema +%% or a reference to a struct by name. -type thrift_type() :: base_type() | collection_type() | enum_type() - | struct_type(). + | struct_type() + | struct_info(). -type base_type() :: bool + | byte | double | i8 | i16 @@ -33,12 +41,17 @@ {enum, type_ref()}. -type struct_type() :: - {struct, struct_flavor(), type_ref()}. + {struct, struct_flavour(), type_ref()}. --type struct_flavor() :: struct | union | exception. +-type struct_flavour() :: struct | union | exception. -type type_ref() :: {module(), Name :: atom()}. +%% Inline struct schema as produced by `:struct_info/1`. +-type struct_info() :: {struct, struct_flavour(), [field_info()]}. + +-type field_info() :: {pos_integer(), atom(), thrift_type(), atom(), term()}. + -type function_schema() :: tuple(). -spec encode(binary, thrift_type(), thrift_value()) -> binary(). diff --git a/apps/dmt/src/dmt_thrift_validator.erl b/apps/dmt/src/dmt_thrift_validator.erl index 2025368..ad157cd 100644 --- a/apps/dmt/src/dmt_thrift_validator.erl +++ b/apps/dmt/src/dmt_thrift_validator.erl @@ -8,16 +8,15 @@ -include_lib("damsel/include/dmsl_domain_thrift.hrl"). -%% @doc Validate a domain object using thrift strict validation --spec validate_domain_object(dmsl_domain_thrift:'DomainObject'()) -> - ok | {error, {invalid, [atom()], term()}}. +%% @doc Validate a domain object using thrift strict validation. Takes any term +%% (validation is the whole point) and either confirms shape or reports errors. +-spec validate_domain_object(term()) -> ok | {error, {invalid, [atom()], term()}}. validate_domain_object(DomainObject) -> Type = dmsl_domain_thrift:struct_info('DomainObject'), thrift_strict_binary_codec:validate({Type, DomainObject}). -%% @doc Validate a reference --spec validate_reference(dmsl_domain_thrift:'Reference'()) -> - ok | {error, {invalid, [atom()], term()}}. +%% @doc Validate a reference. Takes any term — validation is the whole point. +-spec validate_reference(term()) -> ok | {error, {invalid, [atom()], term()}}. validate_reference(PayoutMethodRef) -> Type = dmsl_domain_thrift:struct_info('Reference'), thrift_strict_binary_codec:validate({Type, PayoutMethodRef}). diff --git a/apps/dmt/test/dmt_client_api.erl b/apps/dmt/test/dmt_client_api.erl index be03413..b02437f 100644 --- a/apps/dmt/test/dmt_client_api.erl +++ b/apps/dmt/test/dmt_client_api.erl @@ -15,7 +15,10 @@ new(Context) -> -spec call(Name :: atom(), woody:func(), [any()], t()) -> {ok, _Response} | {exception, _} | {error, _}. call(ServiceName, Function, Args, Context) -> - Service = dmt_sup:get_service(ServiceName), + %% Cast: `dmt_sup:get_service/1` accepts a specific atom union + %% (`repository | repository_client | author`); test callers pass a + %% generic `atom()` and the runtime contract holds. + Service = dmt_sup:get_service(eqwalizer:dynamic_cast(ServiceName)), Request = {Service, Function, list_to_tuple(Args)}, Opts = get_opts(ServiceName), try @@ -30,7 +33,10 @@ get_opts(ServiceName) -> Opts0 = #{ event_handler => {scoper_woody_event_handler, EventHandlerOpts} }, - case maps:get(ServiceName, genlib_app:env(dmt, services), undefined) of + %% Cast: `genlib_app:env/3` returns `term()` because sys.config is opaque + %% to the type system; `maps:get/3` expects a map argument so we assert + %% the runtime shape here. + case maps:get(ServiceName, eqwalizer:dynamic_cast(genlib_app:env(dmt, services, #{})), undefined) of #{} = Opts -> maps:merge(Opts, Opts0); _ -> diff --git a/apps/dmt/test/dmt_repository_filter_test.erl b/apps/dmt/test/dmt_repository_filter_test.erl index b2c84de..23bd024 100644 --- a/apps/dmt/test/dmt_repository_filter_test.erl +++ b/apps/dmt/test/dmt_repository_filter_test.erl @@ -5,6 +5,8 @@ % We modify records in improper way in tests, so we need to suppress dialyzer warnings -dialyzer({nowarn_function, [test_all_invalid_objects/0, test_mixed_valid_invalid_objects/0]}). +-eqwalizer({nowarn_function, test_all_invalid_objects/0}). +-eqwalizer({nowarn_function, test_mixed_valid_invalid_objects/0}). %% Test the filter_search_results/1 function from dmt_repository module diff --git a/apps/dmt_core/src/dmt_domain.erl b/apps/dmt_core/src/dmt_domain.erl index 98ea6de..8d88048 100644 --- a/apps/dmt_core/src/dmt_domain.erl +++ b/apps/dmt_core/src/dmt_domain.erl @@ -34,6 +34,13 @@ {conflict, operation_conflict()} | {invalid, operation_invalid()}. +-type thrift_type() :: dmt_thrift:thrift_type(). +-type struct_info() :: dmt_thrift:struct_info(). +-type field_info() :: dmt_thrift:field_info(). + +-type object_reference() :: {atom(), term()}. + +-spec references(domain_object()) -> [object_reference()]. references(DomainObject) -> case get_data(DomainObject) of {error, _} -> @@ -42,9 +49,12 @@ references(DomainObject) -> references(Data, DataType) end. +-spec references(eqwalizer:dynamic(), thrift_type()) -> [object_reference()]. references(Object, DataType) -> references(Object, DataType, []). +-spec references(eqwalizer:dynamic(), thrift_type(), [object_reference()]) -> + [object_reference()]. references(undefined, _StructInfo, Refs) -> Refs; references({Tag, Object}, {struct, union, FieldsInfo} = StructInfo, Refs) when @@ -102,11 +112,14 @@ references(Object, {map, KeyType, ValueType}, Refs) -> references(_DomainObject, _Primitive, Refs) -> Refs. +-spec indexfold(fun((pos_integer(), Elem, Acc) -> Acc), Acc, pos_integer(), [Elem]) -> Acc. indexfold(Fun, Acc, I, [E | Rest]) -> indexfold(Fun, Fun(I, E, Acc), I + 1, Rest); indexfold(_Fun, Acc, _I, []) -> Acc. +-spec check_reference_type(eqwalizer:dynamic(), thrift_type(), [object_reference()]) -> + [object_reference()]. check_reference_type(Object, Type, Refs) -> case is_reference_type(Type) of {true, Tag} -> @@ -115,10 +128,13 @@ check_reference_type(Object, Type, Refs) -> references(Object, Type, Refs) end. --spec get_data(domain_object()) -> any(). +-spec get_data(domain_object()) -> + {thrift_type(), eqwalizer:dynamic()} | {error, {unknown_domain_object_tag, atom()}}. get_data(DomainObject) -> get_domain_object_field(data, DomainObject). +-spec get_domain_object_field(atom(), domain_object()) -> + {thrift_type(), eqwalizer:dynamic()} | {error, {unknown_domain_object_tag, atom()}}. get_domain_object_field(Field, {Tag, Struct}) -> case get_domain_object_schema(Tag) of {error, _} = Error -> @@ -127,8 +143,10 @@ get_domain_object_field(Field, {Tag, Struct}) -> get_field(Field, Struct, Schema) end. -maybe_get_domain_object_data_field(Field, {Tag, Struct}) -> - try get_data({Tag, Struct}) of +-spec maybe_get_domain_object_data_field(atom(), domain_object()) -> + eqwalizer:dynamic() | undefined. +maybe_get_domain_object_data_field(Field, {Tag, _Struct} = DomainObject) -> + try get_data(DomainObject) of {error, _} -> undefined; {_, Data} -> @@ -136,11 +154,13 @@ maybe_get_domain_object_data_field(Field, {Tag, Struct}) -> catch Error:Reason:Stacktrace -> logger:warning("Error getting data field ~p for ~p: ~p", [ - Field, {Tag, Struct}, {Error, Reason, Stacktrace} + Field, DomainObject, {Error, Reason, Stacktrace} ]), undefined end. +-spec maybe_extract_field_from_data(atom(), atom(), eqwalizer:dynamic()) -> + eqwalizer:dynamic() | undefined. maybe_extract_field_from_data(Field, Tag, Data) -> SchemaInfo = get_struct_info('ReflessDomainObject'), case get_field_info(Tag, SchemaInfo) of @@ -150,6 +170,7 @@ maybe_extract_field_from_data(Field, Tag, Data) -> undefined end. +-spec maybe_get_field_by_index(atom(), atom(), tuple()) -> eqwalizer:dynamic() | undefined. maybe_get_field_by_index(Field, ObjectStructName, Data) -> DomainObjectSchema = get_struct_info(ObjectStructName), case get_field_index(Field, DomainObjectSchema) of @@ -160,32 +181,55 @@ maybe_get_field_by_index(Field, ObjectStructName, Data) -> end. % limit_config is an exception, it's not in domain.thrift +-spec get_domain_object_schema(atom()) -> + struct_info() | {error, {unknown_domain_object_tag, atom()}}. get_domain_object_schema(limit_config) -> - dmsl_limiter_config_thrift:struct_info('LimitConfig'); + %% Cast: `dmsl_limiter_config_thrift` exports its own nominal `struct_info()` + %% type. Our local `struct_info()` is the structurally identical alias — + %% eqwalizer treats the two as distinct nominal types so we need to cross + %% the cross-thrift-module boundary explicitly here. + eqwalizer:dynamic_cast(dmsl_limiter_config_thrift:struct_info('LimitConfig')); get_domain_object_schema(Tag) -> SchemaInfo = get_struct_info('DomainObject'), case get_field_info(Tag, SchemaInfo) of - {_, _, {struct, _, {_, ObjectStructName}}, _, _} -> + {_, _, {struct, _, {_, ObjectStructName}}, _, _} when is_atom(ObjectStructName) -> get_struct_info(ObjectStructName); - false -> + _ -> {error, {unknown_domain_object_tag, Tag}} end. +-spec get_field(atom(), tuple(), struct_info()) -> {thrift_type(), eqwalizer:dynamic()}. get_field(Field, Struct, StructInfo) when is_atom(Field) -> {FieldIndex, {_, _, Type, _, _}} = get_field_index(Field, StructInfo), {Type, element(FieldIndex, Struct)}. +-spec get_struct_info(atom()) -> struct_info(). get_struct_info(StructName) -> - dmsl_domain_thrift:struct_info(StructName). + %% Outer cast: `dmsl_domain_thrift:struct_info/1` returns its nominal + %% `struct_info()` type which is structurally identical to but nominally + %% distinct from our local `struct_info()` alias. + %% Inner cast on `StructName`: the callee expects a specific atom union + %% (`struct_name() | exception_name()`); the value comes from a runtime + %% schema field so we only know it's an `atom()`. + eqwalizer:dynamic_cast( + dmsl_domain_thrift:struct_info(eqwalizer:dynamic_cast(StructName)) + ). +-spec get_field_info(atom(), struct_info()) -> field_info() | false. get_field_info(Field, {struct, _StructType, FieldsInfo}) -> - lists:keyfind(Field, 4, FieldsInfo). + case lists:keyfind(Field, 4, FieldsInfo) of + false -> false; + T -> T + end. +-spec get_field_index(atom(), struct_info()) -> {pos_integer(), field_info()} | false. get_field_index(Field, {struct, _StructType, FieldsInfo}) -> % NOTE % This `2` gives index of the first significant field in a record tuple. get_field_index(Field, 2, FieldsInfo). +-spec get_field_index(atom(), pos_integer(), [field_info()]) -> + {pos_integer(), field_info()} | false. get_field_index(_Field, _, []) -> false; get_field_index(Field, I, [F | Rest]) -> @@ -196,10 +240,16 @@ get_field_index(Field, I, [F | Rest]) -> get_field_index(Field, I + 1, Rest) end. +-spec is_reference_type(thrift_type()) -> {true, atom()} | false. is_reference_type(Type) -> - {struct, union, StructInfo} = get_struct_info('Reference'), - is_reference_type(Type, StructInfo). + case get_struct_info('Reference') of + {struct, union, StructInfo} when is_list(StructInfo) -> + is_reference_type(Type, StructInfo); + _ -> + false + end. +%% NOTE: dmt_domain_pt parse_transform removes is_reference_type/2 — no spec. is_reference_type(_Type, []) -> false; is_reference_type(Type, [{_, _, Type, Tag, _} | _Rest]) -> diff --git a/apps/dmt_core/src/dmt_domain_pt.erl b/apps/dmt_core/src/dmt_domain_pt.erl index 321da22..b07d761 100644 --- a/apps/dmt_core/src/dmt_domain_pt.erl +++ b/apps/dmt_core/src/dmt_domain_pt.erl @@ -5,12 +5,16 @@ -spec parse_transform(Forms, term()) -> Forms when Forms :: [erl_parse:abstract_form() | erl_parse:form_info()]. parse_transform(Forms, _Options) -> - [ + %% Cast: `erl_syntax:revert/1` returns `erl_syntax:syntaxTree()` but the + %% `parse_transform/2` callback contract requires + %% `[erl_parse:abstract_form() | erl_parse:form_info()]`. The two are the + %% same underlying AST representation but have distinct nominal types. + eqwalizer:dynamic_cast([ erl_syntax:revert(FormNext) || Form <- Forms, FormNext <- [erl_syntax_lib:map(fun transform/1, Form)], FormNext /= delete - ]. + ]). transform(Form) -> case erl_syntax:type(Form) of diff --git a/apps/dmt_object/src/dmt_object.erl b/apps/dmt_object/src/dmt_object.erl index 9c0fb60..ee14cd4 100644 --- a/apps/dmt_object/src/dmt_object.erl +++ b/apps/dmt_object/src/dmt_object.erl @@ -13,39 +13,48 @@ -export_type([insertable_object/0]). -export_type([object_changes/0]). -export_type([object/0]). +-export_type([object_type/0]). +-export_type([object_id/0]). +-export_type([timestamp/0]). +-export_type([domain_object/0]). +-export_type([refless_domain_object/0]). -type object_type() :: atom(). --type object_id() :: string(). --type timestamp() :: string(). +-type object_id() :: term(). +-type timestamp() :: binary() | string(). + +-type domain_object() :: dmsl_domain_thrift:'DomainObject'(). +-type refless_domain_object() :: dmsl_domain_thrift:'ReflessDomainObject'(). -type insertable_object() :: #{ + tmp_id := binary(), type := object_type(), - tmp_id := object_id(), - forced_id := string() | undefined, - references := [{object_type(), object_id()}], - data := binary() + forced_id := term() | undefined, + data := refless_domain_object() }. -type object_changes() :: #{ - id := object_id(), + id := {object_type(), object_id()}, type := object_type(), - references => [{object_type(), object_id()}], - referenced_by => [{object_type(), object_id()}], - data => binary(), + %% Carries a domain object value; the shape is enforced by the producer + %% (`update_object/2` / `commit_operation/2`) and not by this type. + data := term(), + referenced_by => [term()], is_active => boolean() }. -type object() :: #{ - id := object_id(), + id := term(), type := object_type(), - version := string(), - references := [{object_type(), object_id()}], - referenced_by := [{object_type(), object_id()}], - data := binary(), - created_at := timestamp(), - created_by := string() + version := number() | binary(), + data := term(), + created_at := binary() | list(), + is_active := boolean(), + atom() => term() }. +-spec new_object(dmsl_domain_conf_v2_thrift:'InsertOp'()) -> + {ok, insertable_object()} | {error, {type_mismatch, term(), term()}}. new_object(#domain_conf_v2_InsertOp{ object = NewObject, force_ref = ForcedRef @@ -62,26 +71,25 @@ new_object(#domain_conf_v2_InsertOp{ {error, Error} end. -update_object( - Object, - ExistingUpdate -) -> - maybe - {ok, Type} ?= get_object_type(Object), - {ok, ID} ?= get_object_ref(Object), - {ok, ExistingUpdate#{ - id => ID, - type => Type, - data => Object - }} - end. +-spec update_object(domain_object() | term(), object_changes()) -> + {ok, object_changes()} | {error, {is_not_domain_object, term()}}. +update_object({Type, {_Record, ID, _Data}} = Object, ExistingUpdate) when is_atom(Type) -> + {ok, ExistingUpdate#{ + id => {Type, ID}, + type => Type, + data => Object + }}; +update_object(Obj, _ExistingUpdate) -> + {error, {is_not_domain_object, Obj}}. +-spec remove_object(object_changes()) -> object_changes(). remove_object(OG) -> OG#{ referenced_by => [], is_active => false }. +-spec just_object(term(), term(), term(), term(), term(), term()) -> object(). just_object( ID, Type, @@ -89,7 +97,12 @@ just_object( Data, CreatedAt, IsActive -) -> +) when + is_atom(Type), + is_integer(Version) orelse is_binary(Version), + is_binary(CreatedAt) orelse is_list(CreatedAt), + is_boolean(IsActive) +-> #{ id => ID, type => Type, @@ -99,6 +112,8 @@ just_object( is_active => IsActive }. +-spec get_checked_type(term() | undefined, refless_domain_object()) -> + {ok, object_type()} | {error, {type_mismatch, term(), term()}}. get_checked_type(undefined, {Type, _}) -> {ok, Type}; get_checked_type({Type, _}, {Type, _}) -> @@ -106,16 +121,7 @@ get_checked_type({Type, _}, {Type, _}) -> get_checked_type(Ref, Object) -> {error, {type_mismatch, Ref, Object}}. -get_object_type({Type, {_Object, _Ref, _Data}}) -> - {ok, Type}; -get_object_type(Obj) -> - {error, {is_not_domain_object, Obj}}. - -get_object_ref({Type, {_Object, ID, _Data}}) -> - {ok, {Type, ID}}; -get_object_ref(Obj) -> - {error, {is_not_domain_object, Obj}}. - +-spec filter_out_inactive_objects([object()]) -> [object()]. filter_out_inactive_objects(Objects) -> lists:filter( fun(Obj) -> diff --git a/apps/dmt_object/src/dmt_object_id.erl b/apps/dmt_object/src/dmt_object_id.erl index c446df8..0c2fbcb 100644 --- a/apps/dmt_object/src/dmt_object_id.erl +++ b/apps/dmt_object/src/dmt_object_id.erl @@ -6,6 +6,11 @@ -export([get_numerical_object_id/2]). -export([get_uuid_object_id/2]). +-type numerical_id() :: integer(). +-type uuid_id() :: binary(). +-type type_tag() :: atom(). + +-spec get_numerical_object_id(type_tag(), numerical_id()) -> tuple() | no_return(). get_numerical_object_id(category, ID) -> #domain_CategoryRef{id = ID}; get_numerical_object_id(business_schedule, ID) -> @@ -43,6 +48,7 @@ get_numerical_object_id(document_type, ID) -> get_numerical_object_id(Type, _ID) -> throw({not_supported, Type}). +-spec get_uuid_object_id(type_tag(), uuid_id()) -> tuple() | no_return(). get_uuid_object_id(party_config, ID) -> #domain_PartyConfigRef{id = ID}; get_uuid_object_id(shop_config, ID) -> diff --git a/apps/dmt_object/src/dmt_object_reference.erl b/apps/dmt_object/src/dmt_object_reference.erl index 439b94f..1d8e785 100644 --- a/apps/dmt_object/src/dmt_object_reference.erl +++ b/apps/dmt_object/src/dmt_object_reference.erl @@ -8,6 +8,13 @@ -define(DOMAIN, dmsl_domain_thrift). +-type object_type() :: dmt_object:object_type(). +-type object_reference() :: {object_type(), term()}. +-type thrift_type() :: dmt_thrift:thrift_type(). +-type struct_info() :: dmt_thrift:struct_info(). +-type field_info() :: dmt_thrift:field_info(). + +-spec get_domain_object_ref(tuple()) -> object_reference(). get_domain_object_ref({Tag, _Struct} = DomainObject) -> {_Type, Ref} = get_domain_object_field(ref, DomainObject), {Tag, Ref}. @@ -15,10 +22,12 @@ get_domain_object_ref({Tag, _Struct} = DomainObject) -> %% RefflessObject ZONE %% FIXME doesn't work +-spec refless_object_references(tuple()) -> [object_reference()]. refless_object_references(DomainObject) -> {Data, DataType} = get_refless_data(DomainObject), references(Data, DataType). +-spec get_refless_data(tuple()) -> {eqwalizer:dynamic(), thrift_type()}. get_refless_data({Tag, Struct}) -> SchemaInfo = get_struct_info('ReflessDomainObject'), case get_field_info(Tag, SchemaInfo) of @@ -30,30 +39,38 @@ get_refless_data({Tag, Struct}) -> %% DomainObject ZONE +-spec domain_object_references(tuple()) -> [object_reference()]. domain_object_references(DomainObject) -> {Data, DataType} = get_domain_object_data(DomainObject), references(Data, DataType). +-spec get_domain_object_data(tuple()) -> {eqwalizer:dynamic(), thrift_type()}. get_domain_object_data(DomainObject) -> get_domain_object_field(data, DomainObject). +-spec get_domain_object_field(atom(), tuple()) -> {eqwalizer:dynamic(), thrift_type()}. get_domain_object_field(Field, {Tag, Struct}) -> get_field(Field, Struct, get_domain_object_schema(Tag)). +-spec get_domain_object_schema(atom()) -> struct_info(). get_domain_object_schema(Tag) -> SchemaInfo = get_struct_info('DomainObject'), {_, _, {struct, _, {_, ObjectStructName}}, _, _} = get_field_info(Tag, SchemaInfo), get_struct_info(ObjectStructName). +-spec get_field(atom(), tuple(), struct_info()) -> {eqwalizer:dynamic(), thrift_type()}. get_field(Field, Struct, StructInfo) when is_atom(Field) -> {FieldIndex, {_, _, Type, _, _}} = get_field_index(Field, StructInfo), {element(FieldIndex, Struct), Type}. +-spec get_field_index(atom(), struct_info()) -> {pos_integer(), field_info()} | false. get_field_index(Field, {struct, _StructType, FieldsInfo}) -> % NOTE % This `2` gives index of the first significant field in a record tuple. get_field_index(Field, 2, FieldsInfo). +-spec get_field_index(atom(), pos_integer(), [field_info()]) -> + {pos_integer(), field_info()} | false. get_field_index(_Field, _, []) -> false; get_field_index(Field, I, [F | Rest]) -> @@ -66,9 +83,12 @@ get_field_index(Field, I, [F | Rest]) -> %% References Gathering ZONE +-spec references(eqwalizer:dynamic(), thrift_type()) -> [object_reference()]. references(Object, DataType) -> references(Object, DataType, []). +-spec references(eqwalizer:dynamic(), thrift_type(), [object_reference()]) -> + [object_reference()]. references(undefined, _StructInfo, Refs) -> Refs; references({Tag, Object}, {struct, union, FieldsInfo} = StructInfo, Refs) when @@ -126,6 +146,8 @@ references(Object, {map, KeyType, ValueType}, Refs) -> references(_DomainObject, _Primitive, Refs) -> Refs. +-spec check_reference_type(eqwalizer:dynamic(), thrift_type(), [object_reference()]) -> + [object_reference()]. check_reference_type(Object, Type, Refs) -> case is_reference_type(Type) of {true, Tag} -> @@ -134,10 +156,16 @@ check_reference_type(Object, Type, Refs) -> references(Object, Type, Refs) end. +-spec is_reference_type(thrift_type()) -> {true, atom()} | false. is_reference_type(Type) -> - {struct, union, StructInfo} = get_struct_info('Reference'), - is_reference_type(Type, StructInfo). + case get_struct_info('Reference') of + {struct, union, StructInfo} when is_list(StructInfo) -> + is_reference_type(Type, StructInfo); + _ -> + false + end. +-spec is_reference_type(thrift_type(), [field_info()]) -> {true, atom()} | false. is_reference_type(_Type, []) -> false; is_reference_type(Type, [{_, _, Type, Tag, _} | _Rest]) -> @@ -145,6 +173,7 @@ is_reference_type(Type, [{_, _, Type, Tag, _} | _Rest]) -> is_reference_type(Type, [_ | Rest]) -> is_reference_type(Type, Rest). +-spec indexfold(fun((pos_integer(), Elem, Acc) -> Acc), Acc, pos_integer(), [Elem]) -> Acc. indexfold(Fun, Acc, I, [E | Rest]) -> indexfold(Fun, Fun(I, E, Acc), I + 1, Rest); indexfold(_Fun, Acc, _I, []) -> @@ -152,13 +181,28 @@ indexfold(_Fun, Acc, _I, []) -> %% Common +-spec get_struct_info(atom()) -> struct_info(). get_struct_info('LimitConfig') -> - dmsl_limiter_config_thrift:struct_info('LimitConfig'); + %% Cast: each `dmsl_*_thrift` module exports its own nominal `struct_info()` + %% type. Ours is the structurally identical local alias — eqwalizer treats + %% the two as distinct nominal types so the cross-module boundary needs an + %% explicit cast. + eqwalizer:dynamic_cast(dmsl_limiter_config_thrift:struct_info('LimitConfig')); get_struct_info(StructName) -> - dmsl_domain_thrift:struct_info(StructName). - + %% Outer cast: same nominal-vs-structural reason as the `LimitConfig` clause. + %% Inner cast on `StructName`: `dmsl_domain_thrift:struct_info/1` expects a + %% specific atom union (`struct_name() | exception_name()`); the value here + %% comes from runtime schema lookups so we only know it's an `atom()`. + eqwalizer:dynamic_cast( + dmsl_domain_thrift:struct_info(eqwalizer:dynamic_cast(StructName)) + ). + +-spec get_field_info(atom(), struct_info()) -> field_info() | false. get_field_info(Field, {struct, _StructType, FieldsInfo}) -> - lists:keyfind(Field, 4, FieldsInfo). + case lists:keyfind(Field, 4, FieldsInfo) of + false -> false; + T -> T + end. -ifdef(TEST). diff --git a/apps/dmt_object/src/dmt_object_type.erl b/apps/dmt_object/src/dmt_object_type.erl index e2373e8..afd3428 100644 --- a/apps/dmt_object/src/dmt_object_type.erl +++ b/apps/dmt_object/src/dmt_object_type.erl @@ -6,6 +6,7 @@ -export([get_refless_object_type/1]). -export([get_ref_type/1]). +-spec get_refless_object_type(tuple()) -> atom() | no_return(). get_refless_object_type(#domain_Category{}) -> category; get_refless_object_type(#domain_Currency{}) -> @@ -13,6 +14,7 @@ get_refless_object_type(#domain_Currency{}) -> get_refless_object_type(_) -> error(not_impl). +-spec get_ref_type(tuple() | undefined) -> atom() | undefined | no_return(). get_ref_type(#domain_CurrencyRef{}) -> currency; get_ref_type(#domain_CategoryRef{}) -> diff --git a/compose.yaml b/compose.yaml index e573ecd..a1c9f73 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,8 @@ services: args: OTP_VERSION: $OTP_VERSION THRIFT_VERSION: $THRIFT_VERSION + ELP_VERSION: $ELP_VERSION + ELP_OTP_VERSION: $ELP_OTP_VERSION volumes: - .:$PWD hostname: dmt.default diff --git a/rebar.config b/rebar.config index 007b567..47b27e9 100644 --- a/rebar.config +++ b/rebar.config @@ -129,7 +129,8 @@ runtime_tools, damsel, jsone, - meck + meck, + eqwalizer_support ]} ]} ]} From 316b3ca5d36c3226e0ec82b51c1a94c271530160 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Mon, 25 May 2026 04:13:54 +0300 Subject: [PATCH 2/3] Fixes --- .github/workflows/static-analysis.yml | 3 +++ .gitignore | 2 ++ apps/dmt/src/dmt_author_database.erl | 2 ++ apps/dmt/src/dmt_author_handler.erl | 2 ++ apps/dmt/src/dmt_database.erl | 2 ++ apps/dmt/src/dmt_repository.erl | 19 +++++++++++++++---- .../dmt/src/dmt_repository_client_handler.erl | 2 ++ apps/dmt/src/dmt_repository_handler.erl | 2 ++ apps/dmt_core/src/dmt_domain.erl | 2 ++ apps/dmt_object/src/dmt_object_id.erl | 2 ++ apps/dmt_object/src/dmt_object_reference.erl | 2 ++ 11 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index fd70ebe..5a1457f 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -8,6 +8,9 @@ on: pull_request: branches: ["**"] +permissions: + contents: read + jobs: eqwalizer: name: Eqwalizer diff --git a/.gitignore b/.gitignore index c894dc3..97b1ea3 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ bin _build _tmp + +.claude diff --git a/apps/dmt/src/dmt_author_database.erl b/apps/dmt/src/dmt_author_database.erl index d615555..a4e64af 100644 --- a/apps/dmt/src/dmt_author_database.erl +++ b/apps/dmt/src/dmt_author_database.erl @@ -19,6 +19,8 @@ -type email() :: dmt_author:email(). -type author() :: dmt_author:author(). +-export_type([worker/0, author_id/0, name/0, email/0, author/0]). + -spec insert(worker(), name(), email()) -> {ok, author_id()} | {ok, {already_exists, author_id()}} | {error, unknown}. insert(Worker, Name, Email) -> diff --git a/apps/dmt/src/dmt_author_handler.erl b/apps/dmt/src/dmt_author_handler.erl index a41c02e..4ec02a1 100644 --- a/apps/dmt/src/dmt_author_handler.erl +++ b/apps/dmt/src/dmt_author_handler.erl @@ -9,6 +9,8 @@ -type options() :: dmt_api_woody_utils:handler_options(). +-export_type([options/0]). + -spec handle_function(woody:func(), woody:args(), woody_context:ctx(), options()) -> {ok, woody:result()} | no_return(). handle_function(Function, Args, WoodyContext0, Options) -> diff --git a/apps/dmt/src/dmt_database.erl b/apps/dmt/src/dmt_database.erl index 29dc94b..b814f70 100644 --- a/apps/dmt/src/dmt_database.erl +++ b/apps/dmt/src/dmt_database.erl @@ -43,6 +43,8 @@ version/0, entity_id/0, entity_type/0, + author_id/0, + object_map/0, sql_error/0, edge_map/0 ]). diff --git a/apps/dmt/src/dmt_repository.erl b/apps/dmt/src/dmt_repository.erl index 82daa6d..a79edc7 100644 --- a/apps/dmt/src/dmt_repository.erl +++ b/apps/dmt/src/dmt_repository.erl @@ -38,6 +38,18 @@ | {conflict, binary()} | eqwalizer:dynamic(). +-export_type([ + worker/0, + version/0, + version_ref/0, + object_ref/0, + author_id/0, + operation/0, + object_map/0, + get_error/0, + commit_error/0 +]). + %% -spec get_object(worker(), version_ref(), object_ref()) -> @@ -816,10 +828,9 @@ get_unique_numerical_id(Worker, Type) -> end. get_unique_uuid(Worker, Type) -> - NewUUID = - case uuid:uuid_to_string(uuid:get_v4_urandom(), binary_standard) of - B when is_binary(B) -> B - end, + %% `uuid:uuid_to_string/2` with `binary_standard` always returns a binary + %% at runtime; assert and narrow with a binary pattern match. + <<_/binary>> = NewUUID = uuid:uuid_to_string(uuid:get_v4_urandom(), binary_standard), NewID = dmt_object_id:get_uuid_object_id(Type, NewUUID), NewRefString = dmt_mapper:ref_to_string({Type, NewID}), case dmt_database:check_if_object_id_active(Worker, NewRefString) of diff --git a/apps/dmt/src/dmt_repository_client_handler.erl b/apps/dmt/src/dmt_repository_client_handler.erl index 3df79f8..ba51795 100644 --- a/apps/dmt/src/dmt_repository_client_handler.erl +++ b/apps/dmt/src/dmt_repository_client_handler.erl @@ -10,6 +10,8 @@ -type options() :: dmt_api_woody_utils:handler_options(). +-export_type([options/0]). + -spec handle_function(woody:func(), woody:args(), woody_context:ctx(), options()) -> {ok, woody:result()} | no_return(). handle_function(Function, Args, WoodyContext0, Options) -> diff --git a/apps/dmt/src/dmt_repository_handler.erl b/apps/dmt/src/dmt_repository_handler.erl index c6c0a82..9935a2f 100644 --- a/apps/dmt/src/dmt_repository_handler.erl +++ b/apps/dmt/src/dmt_repository_handler.erl @@ -9,6 +9,8 @@ -type options() :: dmt_api_woody_utils:handler_options(). +-export_type([options/0]). + -spec handle_function(woody:func(), woody:args(), woody_context:ctx(), options()) -> {ok, woody:result()} | no_return(). handle_function(Function, Args, WoodyContext0, Options) -> diff --git a/apps/dmt_core/src/dmt_domain.erl b/apps/dmt_core/src/dmt_domain.erl index 8d88048..6912775 100644 --- a/apps/dmt_core/src/dmt_domain.erl +++ b/apps/dmt_core/src/dmt_domain.erl @@ -14,6 +14,8 @@ -export_type([operation_error/0]). -export_type([domain_object/0]). +-export_type([thrift_type/0]). +-export_type([object_reference/0]). %% diff --git a/apps/dmt_object/src/dmt_object_id.erl b/apps/dmt_object/src/dmt_object_id.erl index 0c2fbcb..8b59636 100644 --- a/apps/dmt_object/src/dmt_object_id.erl +++ b/apps/dmt_object/src/dmt_object_id.erl @@ -10,6 +10,8 @@ -type uuid_id() :: binary(). -type type_tag() :: atom(). +-export_type([numerical_id/0, uuid_id/0, type_tag/0]). + -spec get_numerical_object_id(type_tag(), numerical_id()) -> tuple() | no_return(). get_numerical_object_id(category, ID) -> #domain_CategoryRef{id = ID}; diff --git a/apps/dmt_object/src/dmt_object_reference.erl b/apps/dmt_object/src/dmt_object_reference.erl index 1d8e785..fa05a80 100644 --- a/apps/dmt_object/src/dmt_object_reference.erl +++ b/apps/dmt_object/src/dmt_object_reference.erl @@ -14,6 +14,8 @@ -type struct_info() :: dmt_thrift:struct_info(). -type field_info() :: dmt_thrift:field_info(). +-export_type([object_reference/0]). + -spec get_domain_object_ref(tuple()) -> object_reference(). get_domain_object_ref({Tag, _Struct} = DomainObject) -> {_Type, Ref} = get_domain_object_field(ref, DomainObject), From b3d5b8ec3f78ce1fe6b1e106072000324ab9ad12 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Mon, 25 May 2026 04:20:48 +0300 Subject: [PATCH 3/3] Allow binary entity_type in dmt_object:just_object/6 The DB stores entity_type as text, so row reads surface as binary. The is_atom(Type) guard added in the typing pass crashed dmt_database:search_objects/6 with function_clause for any object loaded from the DB. Widen object_type() to atom() | binary() and update the guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/dmt_object/src/dmt_object.erl | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/dmt_object/src/dmt_object.erl b/apps/dmt_object/src/dmt_object.erl index ee14cd4..049a89f 100644 --- a/apps/dmt_object/src/dmt_object.erl +++ b/apps/dmt_object/src/dmt_object.erl @@ -19,7 +19,10 @@ -export_type([domain_object/0]). -export_type([refless_domain_object/0]). --type object_type() :: atom(). +%% Object type tag. Constructed as an atom in code (e.g. `category`, +%% `provider`) but stored as text in the DB, so values read back from a row +%% surface as a binary. Both shapes flow through the same APIs. +-type object_type() :: atom() | binary(). -type object_id() :: term(). -type timestamp() :: binary() | string(). @@ -98,7 +101,7 @@ just_object( CreatedAt, IsActive ) when - is_atom(Type), + is_atom(Type) orelse is_binary(Type), is_integer(Version) orelse is_binary(Version), is_binary(CreatedAt) orelse is_list(CreatedAt), is_boolean(IsActive)