From 8b2fa7ad40509c3e628005574eee1332aa0d6dac Mon Sep 17 00:00:00 2001 From: abstrct Date: Tue, 1 Sep 2026 16:36:22 -0400 Subject: [PATCH 1/2] New API endpoints for Guild Admin and charting purposes. --- src/src/Constant/ApiParameters.php | 32 + src/src/Constant/ObjectTypes.php | 45 + src/src/Constant/PaginationLimits.php | 21 + src/src/Constant/RegexPattern.php | 8 + src/src/Controller/CatalogReadController.php | 267 ++- src/src/Controller/FleetController.php | 14 + src/src/Controller/InventoryController.php | 89 + src/src/Controller/LeaderboardController.php | 39 + src/src/Controller/LedgerController.php | 17 + src/src/Controller/PlanetController.php | 52 + src/src/Controller/PlayerController.php | 51 + src/src/Controller/ResolveController.php | 50 + src/src/Controller/StatReadController.php | 45 +- src/src/Controller/StructController.php | 26 + src/src/Controller/TimestampController.php | 20 + src/src/Controller/WorkController.php | 13 + src/src/Dto/ApiRequestParamsDto.php | 49 + src/src/Dto/ApiResponseContentDto.php | 25 +- src/src/Manager/GuildManager.php | 39 +- src/src/Manager/InventoryManager.php | 157 ++ src/src/Manager/LeaderboardManager.php | 174 ++ src/src/Manager/LedgerManager.php | 43 + src/src/Manager/PlanetManager.php | 69 + src/src/Manager/PlayerManager.php | 118 ++ src/src/Manager/ResolveManager.php | 300 +++ src/src/Manager/StatReadManager.php | 255 ++- src/src/Manager/StructManager.php | 51 + src/src/Manager/TableReadManager.php | 1730 +++++++++++------- src/src/Manager/WorkManager.php | 14 + src/src/Trait/ApiSqlQueryTrait.php | 144 +- src/src/Trait/ObjectKeyTrait.php | 33 + src/src/Util/ResponseMetaUtil.php | 38 + src/tests/ApiManagerTestCase.php | 49 + src/tests/ApiResponseContentDtoTest.php | 36 + src/tests/ApiRoutingTest.php | 49 + src/tests/GuildManagerTest.php | 98 + src/tests/InventoryManagerTest.php | 32 + src/tests/LeaderboardManagerTest.php | 41 + src/tests/ObjectTypesTest.php | 35 + src/tests/PaginationLimitsTest.php | 25 + src/tests/ResolveManagerTest.php | 82 + src/tests/StatReadManagerTest.php | 120 ++ src/tests/TableReadManagerTest.php | 180 ++ 43 files changed, 3943 insertions(+), 832 deletions(-) create mode 100644 src/src/Constant/ObjectTypes.php create mode 100644 src/src/Controller/InventoryController.php create mode 100644 src/src/Controller/LeaderboardController.php create mode 100644 src/src/Controller/ResolveController.php create mode 100644 src/src/Manager/InventoryManager.php create mode 100644 src/src/Manager/LeaderboardManager.php create mode 100644 src/src/Manager/ResolveManager.php create mode 100644 src/src/Trait/ObjectKeyTrait.php create mode 100644 src/src/Util/ResponseMetaUtil.php create mode 100644 src/tests/ApiManagerTestCase.php create mode 100644 src/tests/ApiResponseContentDtoTest.php create mode 100644 src/tests/ApiRoutingTest.php create mode 100644 src/tests/GuildManagerTest.php create mode 100644 src/tests/InventoryManagerTest.php create mode 100644 src/tests/LeaderboardManagerTest.php create mode 100644 src/tests/ObjectTypesTest.php create mode 100644 src/tests/PaginationLimitsTest.php create mode 100644 src/tests/ResolveManagerTest.php create mode 100644 src/tests/StatReadManagerTest.php create mode 100644 src/tests/TableReadManagerTest.php diff --git a/src/src/Constant/ApiParameters.php b/src/src/Constant/ApiParameters.php index f7a1e9312..c4b1acfec 100644 --- a/src/src/Constant/ApiParameters.php +++ b/src/src/Constant/ApiParameters.php @@ -6,10 +6,14 @@ class ApiParameters { const string ADDRESS = 'address'; + const string AFTER_ID = 'after_id'; + const string ALLOCATION_ID = 'allocation_id'; const string ATTRIBUTE_TYPE = 'attribute_type'; + const string BUCKET = 'bucket'; + const string CATEGORY = 'category'; const string CODE = 'code'; @@ -38,10 +42,22 @@ class ApiParameters const string GUILD_ID = 'guild_id'; + const string IDS = 'ids'; + + const string INCLUDE_META = 'include_meta'; + + const string INCLUDE_TOTAL = 'include_total'; + const string IP = 'ip'; + const string IS_DESTROYED = 'is_destroyed'; + + const string KIND = 'kind'; + const string LABEL = 'label'; + const string LIMIT = 'limit'; + const string LOCATION_ID = 'location_id'; const string LOGGED_IN_ADDRESS = 'logged_in_address'; @@ -56,10 +72,16 @@ class ApiParameters const string OBJECT_KEY = 'object_key'; + const string OBJECT_TYPE = 'object_type'; + const string OFFSET = 'offset'; + const string ORDER = 'order'; + const string OWNER = 'owner'; + const string OWNER_TYPE = 'owner_type'; + const string PAGE = 'page'; const string PERMISSIONS = 'permissions'; @@ -82,14 +104,20 @@ class ApiParameters const string PUBKEY = 'pubkey'; + const string Q = 'q'; + const string SEARCH_STRING = 'search_string'; const string SIGNATURE = 'signature'; + const string SINCE_SEQ = 'since_seq'; + const string SOURCE_ID = 'source_id'; const string START_TIME = 'start_time'; + const string STATUS = 'status'; + const string STRUCT_ID = 'struct_id'; const string SUBSTATION_ID = 'substation_id'; @@ -98,9 +126,13 @@ class ApiParameters const string UNIX_TIMESTAMP = 'unix_timestamp'; + const string UPDATED_SINCE = 'updated_since'; + const string USER_AGENT = 'user_agent'; const string USERNAME = 'username'; const string VALIDATOR_ADDRESS = 'validator_address'; + + const string WINDOW_BLOCKS = 'window_blocks'; } diff --git a/src/src/Constant/ObjectTypes.php b/src/src/Constant/ObjectTypes.php new file mode 100644 index 000000000..6cbd187d7 --- /dev/null +++ b/src/src/Constant/ObjectTypes.php @@ -0,0 +1,45 @@ + + */ + public const array PREFIXES = [ + '10' => 'provider', + '11' => 'agreement', + '0' => 'guild', + '1' => 'player', + '2' => 'planet', + '3' => 'reactor', + '4' => 'substation', + '5' => 'struct', + '6' => 'allocation', + '7' => 'infusion', + '8' => 'address', + '9' => 'fleet', + ]; + + /** + * The values of PREFIXES, as a list. Kept as a literal because attributes need + * a constant expression; ObjectTypesTest asserts the two stay in step. + */ + public const array ALL = [ + 'provider', + 'agreement', + 'guild', + 'player', + 'planet', + 'reactor', + 'substation', + 'struct', + 'allocation', + 'infusion', + 'address', + 'fleet', + ]; +} diff --git a/src/src/Constant/PaginationLimits.php b/src/src/Constant/PaginationLimits.php index 4d31e7aab..55b55642b 100644 --- a/src/src/Constant/PaginationLimits.php +++ b/src/src/Constant/PaginationLimits.php @@ -5,4 +5,25 @@ class PaginationLimits { const int DEFAULT = 100; + + const int MAX = 1000; + + const int LEADERBOARD_DEFAULT = 50; + + const int BATCH_IDS_MAX = 25; + + const int AGGREGATE_MAX_SECONDS = 2592000; + + /** + * Accepts a raw query-string value so callers do not each repeat the + * null/empty-string dance before casting. + */ + public static function clamp(int|string|null $limit, int $default = self::DEFAULT): int + { + if ($limit === null || $limit === '' || (int) $limit < 1) { + return $default; + } + + return min(self::MAX, (int) $limit); + } } \ No newline at end of file diff --git a/src/src/Constant/RegexPattern.php b/src/src/Constant/RegexPattern.php index 80ab52d21..f400fed64 100644 --- a/src/src/Constant/RegexPattern.php +++ b/src/src/Constant/RegexPattern.php @@ -17,6 +17,14 @@ class RegexPattern /** Composite game object id: type prefix + hyphen + numeric index (e.g. 5-123, 10-1) */ public const string OBJECT_KEY = '/^\d+-\d+$/'; + /** Comma-separated object keys (batch reads), capped at BATCH_IDS_MAX entries. */ + public const string IDS = '/^(\d+-\d+)(,(\d+-\d+)){0,' + . (PaginationLimits::BATCH_IDS_MAX - 1) + . '}$/'; + + /** Allowlisted column.direction order specs. */ + public const string ORDER = '/^[a-z_]+(\.(asc|desc))?$/'; + public const string PERMISSIONS = '/^[0-9]{1,8}$/'; public const string PUBKEY = '/^[a-zA-Z0-9]+$/'; diff --git a/src/src/Controller/CatalogReadController.php b/src/src/Controller/CatalogReadController.php index bae64477e..d97ddba44 100644 --- a/src/src/Controller/CatalogReadController.php +++ b/src/src/Controller/CatalogReadController.php @@ -5,6 +5,7 @@ use App\Manager\TableReadManager; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -18,9 +19,9 @@ class CatalogReadController extends AbstractController { private const string PAGE_REQUIREMENT = '\d+'; - private function manager(EntityManagerInterface $em, ValidatorInterface $v): TableReadManager + private function manager(EntityManagerInterface $em, ValidatorInterface $v, Request $request): TableReadManager { - return new TableReadManager($em, $v); + return (new TableReadManager($em, $v))->applyListQuery($request); } // --- address_tag --- @@ -28,20 +29,22 @@ private function manager(EntityManagerInterface $em, ValidatorInterface $v): Tab #[Route('/api/address-tag/all/page/{page}', name: 'api_address_tag_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function addressTagAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->addressTagAll($page); + return $this->manager($entityManager, $validator, $request)->addressTagAll($page); } #[Route('/api/address-tag/address/{address}/page/{page}', name: 'api_address_tag_by_address', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function addressTagByAddress( string $address, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->addressTagByAddress($address, $page); + return $this->manager($entityManager, $validator, $request)->addressTagByAddress($address, $page); } // --- agreement --- @@ -49,47 +52,62 @@ public function addressTagByAddress( #[Route('/api/agreement/all/page/{page}', name: 'api_agreement_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function agreementAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->agreementAll($page); + return $this->manager($entityManager, $validator, $request)->agreementAll($page); } #[Route('/api/agreement/provider/{provider_id}/page/{page}', name: 'api_agreement_by_provider', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function agreementByProvider( string $provider_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->agreementByProvider($provider_id, $page); + return $this->manager($entityManager, $validator, $request)->agreementByProvider($provider_id, $page); } #[Route('/api/agreement/allocation/{allocation_id}', name: 'api_agreement_by_allocation', methods: ['GET'])] public function agreementByAllocation( string $allocation_id, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->agreementByAllocation($allocation_id); + return $this->manager($entityManager, $validator, $request)->agreementByAllocation($allocation_id); } #[Route('/api/agreement/creator/{creator}', name: 'api_agreement_by_creator', methods: ['GET'])] public function agreementByCreator( string $creator, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->agreementByCreator($creator); + return $this->manager($entityManager, $validator, $request)->agreementByCreator($creator); } #[Route('/api/agreement/owner/{owner}', name: 'api_agreement_by_owner', methods: ['GET'])] public function agreementByOwner( string $owner, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->agreementByOwner($owner); + return $this->manager($entityManager, $validator, $request)->agreementByOwner($owner); + } + + #[Route('/api/agreement/owner/{owner}/market', name: 'api_agreement_by_owner_market', methods: ['GET'])] + public function agreementByOwnerMarket( + string $owner, + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return $this->manager($entityManager, $validator, $request)->agreementByOwnerMarket($owner); } // --- allocation --- @@ -97,60 +115,66 @@ public function agreementByOwner( #[Route('/api/allocation/all/page/{page}', name: 'api_allocation_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function allocationAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->allocationAll($page); + return $this->manager($entityManager, $validator, $request)->allocationAll($page); } #[Route('/api/allocation/source/{source_id}/page/{page}', name: 'api_allocation_by_source', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function allocationBySource( string $source_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->allocationBySource($source_id, $page); + return $this->manager($entityManager, $validator, $request)->allocationBySource($source_id, $page); } #[Route('/api/allocation/destination/{destination_id}/page/{page}', name: 'api_allocation_by_destination', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function allocationByDestination( string $destination_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->allocationByDestination($destination_id, $page); + return $this->manager($entityManager, $validator, $request)->allocationByDestination($destination_id, $page); } #[Route('/api/allocation/creator/{creator}/page/{page}', name: 'api_allocation_by_creator', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function allocationByCreator( string $creator, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->allocationByCreator($creator, $page); + return $this->manager($entityManager, $validator, $request)->allocationByCreator($creator, $page); } #[Route('/api/allocation/controller/{controller}/page/{page}', name: 'api_allocation_by_controller', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function allocationByController( string $controller, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->allocationByController($controller, $page); + return $this->manager($entityManager, $validator, $request)->allocationByController($controller, $page); } // --- banned_word --- #[Route('/api/banned-word/all', name: 'api_banned_word_all', methods: ['GET'])] public function bannedWordAll( + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->bannedWordAll(); + return $this->manager($entityManager, $validator, $request)->bannedWordAll(); } // --- defusion --- @@ -158,30 +182,33 @@ public function bannedWordAll( #[Route('/api/defusion/all/page/{page}', name: 'api_defusion_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function defusionAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->defusionAll($page); + return $this->manager($entityManager, $validator, $request)->defusionAll($page); } #[Route('/api/defusion/validator/{validator_address}/page/{page}', name: 'api_defusion_by_validator', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function defusionByValidator( string $validator_address, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->defusionByValidator($validator_address, $page); + return $this->manager($entityManager, $validator, $request)->defusionByValidator($validator_address, $page); } #[Route('/api/defusion/delegator/{delegator_address}/page/{page}', name: 'api_defusion_by_delegator', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function defusionByDelegator( string $delegator_address, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->defusionByDelegator($delegator_address, $page); + return $this->manager($entityManager, $validator, $request)->defusionByDelegator($delegator_address, $page); } // --- fleet --- @@ -189,20 +216,22 @@ public function defusionByDelegator( #[Route('/api/fleet/list/all/page/{page}', name: 'api_fleet_list_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function fleetAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->fleetAll($page); + return $this->manager($entityManager, $validator, $request)->fleetAll($page); } #[Route('/api/fleet/list/location/{location_id}/page/{page}', name: 'api_fleet_list_by_location', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function fleetByLocation( string $location_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->fleetByLocation($location_id, $page); + return $this->manager($entityManager, $validator, $request)->fleetByLocation($location_id, $page); } // --- grid --- @@ -210,30 +239,51 @@ public function fleetByLocation( #[Route('/api/grid/all/page/{page}', name: 'api_grid_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function gridAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->gridAll($page); + return $this->manager($entityManager, $validator, $request)->gridAll($page); } #[Route('/api/grid/object/{object_id}/page/{page}', name: 'api_grid_by_object', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function gridByObject( string $object_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->gridByObject($object_id, $page); + return $this->manager($entityManager, $validator, $request)->gridByObject($object_id, $page); } #[Route('/api/grid/attribute-type/{attribute_type}/page/{page}', name: 'api_grid_by_attribute_type', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function gridByAttributeType( string $attribute_type, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->gridByAttributeType($attribute_type, $page); + return $this->manager($entityManager, $validator, $request)->gridByAttributeType($attribute_type, $page); + } + + #[Route( + '/api/grid/attribute-type/{attribute_type}/object-type/{object_type}/page/{page}', + name: 'api_grid_by_attribute_type_and_object_type', + requirements: ['page' => self::PAGE_REQUIREMENT], + methods: ['GET'] + )] + public function gridByAttributeTypeAndObjectType( + string $attribute_type, + string $object_type, + int $page, + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return $this->manager($entityManager, $validator, $request) + ->gridByAttributeTypeAndObjectType($attribute_type, $object_type, $page); } // --- guild list --- @@ -241,40 +291,44 @@ public function gridByAttributeType( #[Route('/api/guild/list/all/page/{page}', name: 'api_guild_list_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function guildAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->guildAll($page); + return $this->manager($entityManager, $validator, $request)->guildAll($page); } #[Route('/api/guild/list/primary-reactor/{primary_reactor_id}/page/{page}', name: 'api_guild_list_by_primary_reactor', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function guildByPrimaryReactor( string $primary_reactor_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->guildByPrimaryReactor($primary_reactor_id, $page); + return $this->manager($entityManager, $validator, $request)->guildByPrimaryReactor($primary_reactor_id, $page); } #[Route('/api/guild/list/entry-substation/{entry_substation_id}/page/{page}', name: 'api_guild_list_by_entry_substation', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function guildByEntrySubstation( string $entry_substation_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->guildByEntrySubstation($entry_substation_id, $page); + return $this->manager($entityManager, $validator, $request)->guildByEntrySubstation($entry_substation_id, $page); } #[Route('/api/guild/list/owner/{owner}/page/{page}', name: 'api_guild_list_by_owner', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function guildByOwner( string $owner, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->guildByOwner($owner, $page); + return $this->manager($entityManager, $validator, $request)->guildByOwner($owner, $page); } // --- guild_membership_application --- @@ -282,30 +336,33 @@ public function guildByOwner( #[Route('/api/guild-membership-application/all/page/{page}', name: 'api_guild_membership_application_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function guildMembershipApplicationAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->guildMembershipApplicationAll($page); + return $this->manager($entityManager, $validator, $request)->guildMembershipApplicationAll($page); } #[Route('/api/guild-membership-application/guild/{guild_id}/page/{page}', name: 'api_guild_membership_application_by_guild', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function guildMembershipApplicationByGuild( string $guild_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->guildMembershipApplicationByGuild($guild_id, $page); + return $this->manager($entityManager, $validator, $request)->guildMembershipApplicationByGuild($guild_id, $page); } #[Route('/api/guild-membership-application/player/{player_id}/page/{page}', name: 'api_guild_membership_application_by_player', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function guildMembershipApplicationByPlayer( string $player_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->guildMembershipApplicationByPlayer($player_id, $page); + return $this->manager($entityManager, $validator, $request)->guildMembershipApplicationByPlayer($player_id, $page); } // --- infusion list --- @@ -313,40 +370,44 @@ public function guildMembershipApplicationByPlayer( #[Route('/api/infusion/list/all/page/{page}', name: 'api_infusion_list_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function infusionAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->infusionAll($page); + return $this->manager($entityManager, $validator, $request)->infusionAll($page); } #[Route('/api/infusion/list/destination/{destination_id}/page/{page}', name: 'api_infusion_list_by_destination', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function infusionByDestination( string $destination_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->infusionByDestination($destination_id, $page); + return $this->manager($entityManager, $validator, $request)->infusionByDestination($destination_id, $page); } #[Route('/api/infusion/list/address/{address}/page/{page}', name: 'api_infusion_list_by_address', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function infusionByAddress( string $address, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->infusionByAddress($address, $page); + return $this->manager($entityManager, $validator, $request)->infusionByAddress($address, $page); } #[Route('/api/infusion/list/player/{player_id}/page/{page}', name: 'api_infusion_list_by_player', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function infusionByPlayerList( string $player_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->infusionByPlayerList($player_id, $page); + return $this->manager($entityManager, $validator, $request)->infusionByPlayerList($player_id, $page); } // --- ledger list (avoid /api/ledger/{tx_id} shadowing) --- @@ -354,30 +415,33 @@ public function infusionByPlayerList( #[Route('/api/ledger/list/all/page/{page}', name: 'api_ledger_list_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function ledgerListAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->ledgerListAll($page); + return $this->manager($entityManager, $validator, $request)->ledgerListAll($page); } #[Route('/api/ledger/list/player/{player_id}/page/{page}', name: 'api_ledger_list_by_player', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function ledgerListByPlayer( string $player_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->ledgerListByPlayer($player_id, $page); + return $this->manager($entityManager, $validator, $request)->ledgerListByPlayer($player_id, $page); } #[Route('/api/ledger/list/address/{address}/page/{page}', name: 'api_ledger_list_by_address', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function ledgerListByAddress( string $address, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->ledgerListByAddress($address, $page); + return $this->manager($entityManager, $validator, $request)->ledgerListByAddress($address, $page); } // --- permission --- @@ -385,30 +449,33 @@ public function ledgerListByAddress( #[Route('/api/permission/all/page/{page}', name: 'api_permission_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function permissionAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->permissionAll($page); + return $this->manager($entityManager, $validator, $request)->permissionAll($page); } #[Route('/api/permission/object/{object_id}/page/{page}', name: 'api_permission_by_object', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function permissionByObject( string $object_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->permissionByObject($object_id, $page); + return $this->manager($entityManager, $validator, $request)->permissionByObject($object_id, $page); } #[Route('/api/permission/player/{player_id}/page/{page}', name: 'api_permission_by_player', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function permissionByPlayer( string $player_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->permissionByPlayer($player_id, $page); + return $this->manager($entityManager, $validator, $request)->permissionByPlayer($player_id, $page); } // --- permission_guild_rank --- @@ -416,30 +483,33 @@ public function permissionByPlayer( #[Route('/api/permission-guild-rank/all/page/{page}', name: 'api_permission_guild_rank_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function permissionGuildRankAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->permissionGuildRankAll($page); + return $this->manager($entityManager, $validator, $request)->permissionGuildRankAll($page); } #[Route('/api/permission-guild-rank/object/{object_id}/page/{page}', name: 'api_permission_guild_rank_by_object', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function permissionGuildRankByObject( string $object_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->permissionGuildRankByObject($object_id, $page); + return $this->manager($entityManager, $validator, $request)->permissionGuildRankByObject($object_id, $page); } #[Route('/api/permission-guild-rank/guild/{guild_id}/page/{page}', name: 'api_permission_guild_rank_by_guild', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function permissionGuildRankByGuild( string $guild_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->permissionGuildRankByGuild($guild_id, $page); + return $this->manager($entityManager, $validator, $request)->permissionGuildRankByGuild($guild_id, $page); } // --- planet list --- @@ -447,51 +517,68 @@ public function permissionGuildRankByGuild( #[Route('/api/planet/list/all/page/{page}', name: 'api_planet_list_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function planetListAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->planetListAll($page); + return $this->manager($entityManager, $validator, $request)->planetListAll($page); } #[Route('/api/planet/list/owner/{owner}/page/{page}', name: 'api_planet_list_by_owner', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function planetListByOwner( string $owner, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->planetListByOwner($owner, $page); + return $this->manager($entityManager, $validator, $request)->planetListByOwner($owner, $page); } // --- planet_activity --- + #[Route('/api/planet-activity/stats', name: 'api_planet_activity_stats', methods: ['GET'])] + public function planetActivityStats( + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return $this->manager($entityManager, $validator, $request)->planetActivityStats( + $request->query->get('category'), + $request->query->get('bucket') + ); + } + #[Route('/api/planet-activity/all/page/{page}', name: 'api_planet_activity_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function planetActivityAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->planetActivityAll($page); + return $this->manager($entityManager, $validator, $request)->planetActivityAll($page); } #[Route('/api/planet-activity/planet/{planet_id}/page/{page}', name: 'api_planet_activity_by_planet', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function planetActivityByPlanet( string $planet_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->planetActivityByPlanet($planet_id, $page); + return $this->manager($entityManager, $validator, $request)->planetActivityByPlanet($planet_id, $page); } #[Route('/api/planet-activity/category/{category}/page/{page}', name: 'api_planet_activity_by_category', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function planetActivityByCategory( string $category, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->planetActivityByCategory($category, $page); + return $this->manager($entityManager, $validator, $request)->planetActivityByCategory($category, $page); } // --- player list --- @@ -499,71 +586,87 @@ public function planetActivityByCategory( #[Route('/api/player/list/all/page/{page}', name: 'api_player_list_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function playerListAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->playerListAll($page); + return $this->manager($entityManager, $validator, $request)->playerListAll($page); } #[Route('/api/player/list/guild/{guild_id}/page/{page}', name: 'api_player_list_by_guild', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function playerListByGuild( string $guild_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->playerListByGuild($guild_id, $page); + return $this->manager($entityManager, $validator, $request)->playerListByGuild($guild_id, $page); } #[Route('/api/player/list/substation/{substation_id}/page/{page}', name: 'api_player_list_by_substation', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function playerListBySubstation( string $substation_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->playerListBySubstation($substation_id, $page); + return $this->manager($entityManager, $validator, $request)->playerListBySubstation($substation_id, $page); } // --- provider --- + #[Route('/api/provider/market', name: 'api_provider_market', methods: ['GET'])] + public function providerMarket( + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return $this->manager($entityManager, $validator, $request)->providerMarket(); + } + #[Route('/api/provider/all/page/{page}', name: 'api_provider_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function providerAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->providerAll($page); + return $this->manager($entityManager, $validator, $request)->providerAll($page); } #[Route('/api/provider/owner/{owner}/page/{page}', name: 'api_provider_by_owner', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function providerByOwner( string $owner, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->providerByOwner($owner, $page); + return $this->manager($entityManager, $validator, $request)->providerByOwner($owner, $page); } #[Route('/api/provider/denom/{denom}/page/{page}', name: 'api_provider_by_denom', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function providerByDenom( string $denom, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->providerByDenom($denom, $page); + return $this->manager($entityManager, $validator, $request)->providerByDenom($denom, $page); } #[Route('/api/provider/substation/{substation_id}/page/{page}', name: 'api_provider_by_substation', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function providerBySubstation( string $substation_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->providerBySubstation($substation_id, $page); + return $this->manager($entityManager, $validator, $request)->providerBySubstation($substation_id, $page); } // --- reactor --- @@ -571,40 +674,44 @@ public function providerBySubstation( #[Route('/api/reactor/all/page/{page}', name: 'api_reactor_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function reactorAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->reactorAll($page); + return $this->manager($entityManager, $validator, $request)->reactorAll($page); } #[Route('/api/reactor/validator/{validator_address}/page/{page}', name: 'api_reactor_by_validator', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function reactorByValidator( string $validator_address, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->reactorByValidator($validator_address, $page); + return $this->manager($entityManager, $validator, $request)->reactorByValidator($validator_address, $page); } #[Route('/api/reactor/guild/{guild_id}/page/{page}', name: 'api_reactor_by_guild', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function reactorByGuild( string $guild_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->reactorByGuild($guild_id, $page); + return $this->manager($entityManager, $validator, $request)->reactorByGuild($guild_id, $page); } #[Route('/api/reactor/owner/{owner}/page/{page}', name: 'api_reactor_by_owner', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function reactorByOwner( string $owner, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->reactorByOwner($owner, $page); + return $this->manager($entityManager, $validator, $request)->reactorByOwner($owner, $page); } // --- substation --- @@ -612,20 +719,22 @@ public function reactorByOwner( #[Route('/api/substation/all/page/{page}', name: 'api_substation_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function substationAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->substationAll($page); + return $this->manager($entityManager, $validator, $request)->substationAll($page); } #[Route('/api/substation/owner/{owner}/page/{page}', name: 'api_substation_by_owner', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function substationByOwner( string $owner, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->substationByOwner($owner, $page); + return $this->manager($entityManager, $validator, $request)->substationByOwner($owner, $page); } // --- struct list --- @@ -633,30 +742,33 @@ public function substationByOwner( #[Route('/api/struct/list/all/page/{page}', name: 'api_struct_list_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function structListAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->structListAll($page); + return $this->manager($entityManager, $validator, $request)->structListAll($page); } #[Route('/api/struct/list/owner/{owner}/page/{page}', name: 'api_struct_list_by_owner', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function structListByOwner( string $owner, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->structListByOwner($owner, $page); + return $this->manager($entityManager, $validator, $request)->structListByOwner($owner, $page); } #[Route('/api/struct/list/location/{location_id}/page/{page}', name: 'api_struct_list_by_location', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function structListByLocation( string $location_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->structListByLocation($location_id, $page); + return $this->manager($entityManager, $validator, $request)->structListByLocation($location_id, $page); } // --- struct_attribute --- @@ -664,30 +776,33 @@ public function structListByLocation( #[Route('/api/struct-attribute/all/page/{page}', name: 'api_struct_attribute_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function structAttributeAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->structAttributeAll($page); + return $this->manager($entityManager, $validator, $request)->structAttributeAll($page); } #[Route('/api/struct-attribute/object/{object_id}/page/{page}', name: 'api_struct_attribute_by_object', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function structAttributeByObject( string $object_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->structAttributeByObject($object_id, $page); + return $this->manager($entityManager, $validator, $request)->structAttributeByObject($object_id, $page); } #[Route('/api/struct-attribute/type/{attribute_type}/page/{page}', name: 'api_struct_attribute_by_type', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function structAttributeByType( string $attribute_type, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->structAttributeByType($attribute_type, $page); + return $this->manager($entityManager, $validator, $request)->structAttributeByType($attribute_type, $page); } // --- struct_defender --- @@ -695,29 +810,32 @@ public function structAttributeByType( #[Route('/api/struct-defender/all/page/{page}', name: 'api_struct_defender_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function structDefenderAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->structDefenderAll($page); + return $this->manager($entityManager, $validator, $request)->structDefenderAll($page); } #[Route('/api/struct-defender/defending/{defending_struct_id}', name: 'api_struct_defender_by_defending', methods: ['GET'])] public function structDefenderByDefending( string $defending_struct_id, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->structDefenderByDefending($defending_struct_id); + return $this->manager($entityManager, $validator, $request)->structDefenderByDefending($defending_struct_id); } #[Route('/api/struct-defender/protected/{protected_struct_id}/page/{page}', name: 'api_struct_defender_by_protected', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function structDefenderByProtected( string $protected_struct_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->structDefenderByProtected($protected_struct_id, $page); + return $this->manager($entityManager, $validator, $request)->structDefenderByProtected($protected_struct_id, $page); } // --- planet_attribute --- @@ -725,29 +843,32 @@ public function structDefenderByProtected( #[Route('/api/planet-attribute/all/page/{page}', name: 'api_planet_attribute_all', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function planetAttributeAll( int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->planetAttributeAll($page); + return $this->manager($entityManager, $validator, $request)->planetAttributeAll($page); } #[Route('/api/planet-attribute/object/{object_id}/page/{page}', name: 'api_planet_attribute_by_object', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function planetAttributeByObject( string $object_id, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->planetAttributeByObject($object_id, $page); + return $this->manager($entityManager, $validator, $request)->planetAttributeByObject($object_id, $page); } #[Route('/api/planet-attribute/type/{attribute_type}/page/{page}', name: 'api_planet_attribute_by_type', requirements: ['page' => self::PAGE_REQUIREMENT], methods: ['GET'])] public function planetAttributeByType( string $attribute_type, int $page, + Request $request, EntityManagerInterface $entityManager, ValidatorInterface $validator ): Response { - return $this->manager($entityManager, $validator)->planetAttributeByType($attribute_type, $page); + return $this->manager($entityManager, $validator, $request)->planetAttributeByType($attribute_type, $page); } } diff --git a/src/src/Controller/FleetController.php b/src/src/Controller/FleetController.php index 710a6bc99..e5bd2f652 100644 --- a/src/src/Controller/FleetController.php +++ b/src/src/Controller/FleetController.php @@ -3,6 +3,7 @@ namespace App\Controller; use App\Manager\FleetManager; +use App\Manager\TableReadManager; use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -12,6 +13,19 @@ class FleetController extends AbstractController { + /** + * @throws Exception + */ + #[Route('/api/fleet/count', name: 'api_count_fleets', methods: ['GET'])] + public function countFleets( + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + $tableReadManager = new TableReadManager($entityManager, $validator); + + return $tableReadManager->countFleets(); + } + /** * @param string $player_id * @param EntityManagerInterface $entityManager diff --git a/src/src/Controller/InventoryController.php b/src/src/Controller/InventoryController.php new file mode 100644 index 000000000..3f7b2bfc4 --- /dev/null +++ b/src/src/Controller/InventoryController.php @@ -0,0 +1,89 @@ + self::PAGE_REQUIREMENT], + methods: ['GET'] + )] + public function inventoryByDenom( + string $denom, + int $page, + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return $this->manager($entityManager, $validator)->inventoryByDenom( + $denom, + $page, + $request->query->get('limit') + ); + } + + /** + * @throws Exception + */ + #[Route( + '/api/inventory/owner/{owner_type}/{owner_id}', + name: 'api_inventory_by_owner', + methods: ['GET'] + )] + public function inventoryByOwner( + string $owner_type, + string $owner_id, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return $this->manager($entityManager, $validator)->inventoryByOwner($owner_type, $owner_id); + } + + /** + * @throws Exception + */ + #[Route('/api/guild-bank', name: 'api_guild_bank', methods: ['GET'])] + public function getGuildBank( + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return $this->manager($entityManager, $validator)->getGuildBank(); + } + + /** + * @throws Exception + */ + #[Route('/api/guild-bank/{guild_id}/history', name: 'api_guild_bank_history', methods: ['GET'])] + public function getGuildBankHistory( + string $guild_id, + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return $this->manager($entityManager, $validator)->getGuildBankHistory( + $guild_id, + $request->query->get('bucket') + ); + } +} diff --git a/src/src/Controller/LeaderboardController.php b/src/src/Controller/LeaderboardController.php new file mode 100644 index 000000000..25db8375c --- /dev/null +++ b/src/src/Controller/LeaderboardController.php @@ -0,0 +1,39 @@ + 'player|guild|reactor|substation|provider'], + methods: ['GET'] + )] + public function getLeaderboard( + string $kind, + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + $manager = new LeaderboardManager($entityManager, $validator); + + return $manager->getLeaderboard( + $kind, + $request->query->get('order'), + $request->query->get('limit') + ); + } +} diff --git a/src/src/Controller/LedgerController.php b/src/src/Controller/LedgerController.php index e1ff0b5fb..ddab01102 100644 --- a/src/src/Controller/LedgerController.php +++ b/src/src/Controller/LedgerController.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -49,6 +50,22 @@ public function countTransactions( return $ledgerManager->countTransactions($player_id); } + /** + * @throws Exception + */ + #[Route('/api/ledger/stats', name: 'api_ledger_stats', methods: ['GET'])] + public function getLedgerStats( + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + $ledgerManager = new LedgerManager($entityManager, $validator); + return $ledgerManager->getLedgerStats( + $request->query->get('bucket'), + $request->query->get('denom') + ); + } + /** * @param string $tx_id * @param EntityManagerInterface $entityManager diff --git a/src/src/Controller/PlanetController.php b/src/src/Controller/PlanetController.php index 3b796e18e..d3d232226 100644 --- a/src/src/Controller/PlanetController.php +++ b/src/src/Controller/PlanetController.php @@ -6,12 +6,64 @@ use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Validator\Validator\ValidatorInterface; class PlanetController extends AbstractController { + /** + * @throws Exception + */ + #[Route('/api/planet/count', name: 'api_count_planets', methods: ['GET'])] + public function countPlanets( + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + $planetManager = new PlanetManager($entityManager, $validator); + return $planetManager->countPlanets(); + } + + /** + * @throws Exception + */ + #[Route( + '/api/planet-raid/all/page/{page}', + name: 'api_planet_raid_all', + requirements: ['page' => '\d+'], + methods: ['GET'] + )] + public function planetRaidAll( + int $page, + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + $planetManager = new PlanetManager($entityManager, $validator); + return $planetManager->planetRaidAll($page, $request->query->get('limit')); + } + + /** + * @throws Exception + */ + #[Route( + '/api/planet-raid/status/{status}/page/{page}', + name: 'api_planet_raid_by_status', + requirements: ['page' => '\d+'], + methods: ['GET'] + )] + public function planetRaidByStatus( + string $status, + int $page, + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + $planetManager = new PlanetManager($entityManager, $validator); + return $planetManager->planetRaidByStatus($status, $page, $request->query->get('limit')); + } + /** * @param string $planet_id * @param EntityManagerInterface $entityManager diff --git a/src/src/Controller/PlayerController.php b/src/src/Controller/PlayerController.php index 252dace33..cd212460e 100644 --- a/src/src/Controller/PlayerController.php +++ b/src/src/Controller/PlayerController.php @@ -13,6 +13,57 @@ class PlayerController extends AbstractController { + /** + * @throws Exception + */ + #[Route('/api/player/count', name: 'api_count_players', methods: ['GET'])] + public function countPlayers( + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return (new PlayerManager($entityManager, $validator))->countPlayers(); + } + + /** + * @throws Exception + */ + #[Route('/api/player/active/count', name: 'api_count_active_players', methods: ['GET'])] + public function countActivePlayers( + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return (new PlayerManager($entityManager, $validator))->countActivePlayers( + $request->query->get('window_blocks') + ); + } + + /** + * @throws Exception + */ + #[Route('/api/player/power/at-risk', name: 'api_player_power_at_risk', methods: ['GET'])] + public function getPlayersAtRisk( + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return (new PlayerManager($entityManager, $validator))->getPlayersAtRisk( + $request->query->get('limit') + ); + } + + /** + * @throws Exception + */ + #[Route('/api/player/{player_id}/power', name: 'api_get_player_power', methods: ['GET'])] + public function getPlayerPower( + string $player_id, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return (new PlayerManager($entityManager, $validator))->getPlayerPower($player_id); + } + /** * @param string $player_id * @param EntityManagerInterface $entityManager diff --git a/src/src/Controller/ResolveController.php b/src/src/Controller/ResolveController.php new file mode 100644 index 000000000..3e96504f4 --- /dev/null +++ b/src/src/Controller/ResolveController.php @@ -0,0 +1,50 @@ +getDenoms(); + } + + /** + * @throws Exception + */ + #[Route('/api/resolve', name: 'api_resolve', methods: ['GET'])] + public function resolve( + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return (new ResolveManager($entityManager, $validator))->resolve($request->query->get('q')); + } + + /** + * @throws Exception + */ + #[Route('/api/objects', name: 'api_objects', methods: ['GET'])] + public function getObjects( + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + return (new ResolveManager($entityManager, $validator))->getObjects($request->query->get('ids')); + } +} diff --git a/src/src/Controller/StatReadController.php b/src/src/Controller/StatReadController.php index b4f0ac039..d5a2f4803 100644 --- a/src/src/Controller/StatReadController.php +++ b/src/src/Controller/StatReadController.php @@ -43,6 +43,49 @@ public function statRangeByObject( $manager = new StatReadManager($entityManager, $validator); - return $manager->getStatRange($metric, $object_key, $page, (string) $start, (string) $end); + return $manager->getStatRange( + $metric, + $object_key, + $page, + (string) $start, + (string) $end, + $request->query->get('bucket'), + $request->query->get('limit') + ); + } + + /** + * @throws Exception + */ + #[Route( + '/api/stat/{metric}/aggregate/range', + name: 'api_stat_aggregate_range', + methods: ['GET'] + )] + public function statAggregateRange( + Request $request, + string $metric, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + $start = $request->query->get('start_time'); + $end = $request->query->get('end_time'); + $objectType = $request->query->get('object_type'); + if ($start === null || $end === null || $objectType === null) { + $body = new ApiResponseContentDto(); + $body->errors = ['object_type_start_time_end_time_required' => 'object_type, start_time and end_time query params are required']; + + return new JsonResponse($body, Response::HTTP_BAD_REQUEST); + } + + $manager = new StatReadManager($entityManager, $validator); + + return $manager->getStatAggregate( + $metric, + (string) $objectType, + (string) $start, + (string) $end, + $request->query->get('bucket') + ); } } diff --git a/src/src/Controller/StructController.php b/src/src/Controller/StructController.php index aeb51ebb6..3595190d7 100644 --- a/src/src/Controller/StructController.php +++ b/src/src/Controller/StructController.php @@ -6,12 +6,38 @@ use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Validator\Validator\ValidatorInterface; class StructController extends AbstractController { + /** + * @throws Exception + */ + #[Route('/api/struct/status/counts', name: 'api_struct_status_counts', methods: ['GET'])] + public function getStructStatusCounts( + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + $structManager = new StructManager($entityManager, $validator); + return $structManager->getStructStatusCounts(); + } + + /** + * @throws Exception + */ + #[Route('/api/struct/count', name: 'api_count_structs', methods: ['GET'])] + public function countStructs( + Request $request, + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + $structManager = new StructManager($entityManager, $validator); + return $structManager->countStructs($request->query->get('is_destroyed')); + } + /** * @param string $player_id * @param EntityManagerInterface $entityManager diff --git a/src/src/Controller/TimestampController.php b/src/src/Controller/TimestampController.php index bbbe2871b..8f0f26319 100644 --- a/src/src/Controller/TimestampController.php +++ b/src/src/Controller/TimestampController.php @@ -4,6 +4,9 @@ use App\Constant\ApiParameters; use App\Dto\ApiResponseContentDto; +use App\Util\ResponseMetaUtil; +use Doctrine\DBAL\Exception; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -19,4 +22,21 @@ public function getUnixTimestamp(): Response $responseContent->success = true; return new JsonResponse($responseContent, Response::HTTP_OK); } + + /** + * @throws Exception + */ + #[Route('/api/block', name: 'api_get_current_block', methods: ['GET'])] + public function getCurrentBlock(EntityManagerInterface $entityManager): Response + { + $responseContent = new ApiResponseContentDto(); + $row = $entityManager->getConnection()->fetchAssociative( + 'SELECT height, tip_height, lag_blocks, status, updated_at FROM current_block LIMIT 1' + ); + $responseContent->data = $row === false ? null : $row; + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $entityManager); + + return new JsonResponse($responseContent, Response::HTTP_OK); + } } diff --git a/src/src/Controller/WorkController.php b/src/src/Controller/WorkController.php index 6c4889fe2..33efa6b92 100644 --- a/src/src/Controller/WorkController.php +++ b/src/src/Controller/WorkController.php @@ -12,6 +12,19 @@ class WorkController extends AbstractController { + /** + * @throws Exception + */ + #[Route('/api/work/count', name: 'api_count_work', methods: ['GET'])] + public function countWork( + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ): Response { + $workManager = new WorkManager($entityManager, $validator); + + return $workManager->countWork(); + } + /** * @throws Exception */ diff --git a/src/src/Dto/ApiRequestParamsDto.php b/src/src/Dto/ApiRequestParamsDto.php index 7e3fa1cc8..de9e9f859 100644 --- a/src/src/Dto/ApiRequestParamsDto.php +++ b/src/src/Dto/ApiRequestParamsDto.php @@ -2,11 +2,15 @@ namespace App\Dto; +use App\Constant\ObjectTypes; use App\Constant\RegexPattern; use Symfony\Component\Validator\Constraints as Assert; class ApiRequestParamsDto { + #[Assert\Regex(RegexPattern::ID)] + public ?string $after_id = null; + #[Assert\Regex(RegexPattern::ADDRESS)] public ?string $address = null; @@ -16,6 +20,9 @@ class ApiRequestParamsDto #[Assert\Regex(RegexPattern::SLUG)] public ?string $attribute_type = null; + #[Assert\Choice(choices: ['1h', '1d'])] + public ?string $bucket = null; + #[Assert\Regex(RegexPattern::SLUG)] public ?string $category = null; @@ -58,12 +65,30 @@ class ApiRequestParamsDto #[Assert\Regex(RegexPattern::ID)] public ?string $guild_id = null; + #[Assert\Regex(RegexPattern::IDS)] + public ?string $ids = null; + + #[Assert\Choice(choices: ['0', '1'])] + public ?string $include_meta = null; + + #[Assert\Choice(choices: ['0', '1'])] + public ?string $include_total = null; + #[Assert\Regex(RegexPattern::INET)] public ?string $ip = null; + #[Assert\Choice(choices: ['0', '1'])] + public ?string $is_destroyed = null; + + #[Assert\Choice(choices: ['player', 'guild', 'reactor', 'substation', 'provider'])] + public ?string $kind = null; + #[Assert\Regex(RegexPattern::SLUG)] public ?string $label = null; + #[Assert\Regex(RegexPattern::INT)] + public ?string $limit = null; + #[Assert\Regex(RegexPattern::ID)] public ?string $location_id = null; @@ -88,12 +113,21 @@ class ApiRequestParamsDto #[Assert\Regex(RegexPattern::OBJECT_KEY)] public ?string $object_key = null; + #[Assert\Choice(choices: ObjectTypes::ALL)] + public ?string $object_type = null; + #[Assert\Regex(RegexPattern::INT)] public ?string $offset = null; + #[Assert\Regex(RegexPattern::ORDER)] + public ?string $order = null; + #[Assert\Regex(RegexPattern::ID)] public ?string $owner = null; + #[Assert\Choice(choices: ObjectTypes::ALL)] + public ?string $owner_type = null; + #[Assert\Regex(RegexPattern::INT)] public ?string $page = null; @@ -127,18 +161,27 @@ class ApiRequestParamsDto #[Assert\Regex(RegexPattern::PUBKEY)] public ?string $pubkey = null; + #[Assert\Length(min: 1, max: 128)] + public ?string $q = null; + #[Assert\Regex(RegexPattern::SEARCH_STRING)] public ?string $search_string = null; #[Assert\Regex(RegexPattern::SIGNATURE)] public ?string $signature = null; + #[Assert\Regex(RegexPattern::INT)] + public ?string $since_seq = null; + #[Assert\Regex(RegexPattern::ID)] public ?string $source_id = null; #[Assert\Regex(RegexPattern::INT)] public ?string $start_time = null; + #[Assert\Regex(RegexPattern::SLUG)] + public ?string $status = null; + #[Assert\Regex(RegexPattern::ID)] public ?string $struct_id = null; @@ -151,6 +194,9 @@ class ApiRequestParamsDto #[Assert\Regex(RegexPattern::INT)] public ?string $unix_timestamp = null; + #[Assert\Regex(RegexPattern::INT)] + public ?string $updated_since = null; + #[Assert\Length(min: 0, max: 255)] public ?string $user_agent = null; @@ -159,4 +205,7 @@ class ApiRequestParamsDto #[Assert\Regex(RegexPattern::ADDRESS)] public ?string $validator_address = null; + + #[Assert\Regex(RegexPattern::INT)] + public ?string $window_blocks = null; } diff --git a/src/src/Dto/ApiResponseContentDto.php b/src/src/Dto/ApiResponseContentDto.php index c2e7d4860..881777053 100644 --- a/src/src/Dto/ApiResponseContentDto.php +++ b/src/src/Dto/ApiResponseContentDto.php @@ -2,11 +2,34 @@ namespace App\Dto; -class ApiResponseContentDto +class ApiResponseContentDto implements \JsonSerializable { public bool $success = false; public array $errors = []; public object|array|null $data = null; + + public mixed $total = null; + + public ?array $meta = null; + + public function jsonSerialize(): array + { + $payload = [ + 'success' => $this->success, + 'errors' => $this->errors, + 'data' => $this->data, + ]; + + if ($this->total !== null) { + $payload['total'] = $this->total; + } + + if ($this->meta !== null) { + $payload['meta'] = $this->meta; + } + + return $payload; + } } diff --git a/src/src/Manager/GuildManager.php b/src/src/Manager/GuildManager.php index 0f656bd4d..9fd9114cd 100644 --- a/src/src/Manager/GuildManager.php +++ b/src/src/Manager/GuildManager.php @@ -204,7 +204,11 @@ public function getGuildPowerStats(string $guild_id): Response sum(COALESCE(i.fuel, 0)) as total_fuel, sum(COALESCE(vp.total_load, 0)) as total_load, sum(COALESCE(vp.total_capacity, 0)) as total_capacity, - floor(avg(COALESCE(vp.connection_capacity, 0))) as avg_connection_capacity + floor(avg(COALESCE(vp.connection_capacity, 0))) as avg_connection_capacity, + sum(COALESCE(i.fuel_p, 0)) as total_fuel_p, + sum(COALESCE(vp.total_load_p, 0)) as total_load_p, + sum(COALESCE(vp.total_capacity_p, 0)) as total_capacity_p, + avg(COALESCE(vp.connection_capacity_p, 0)) as avg_connection_capacity_p FROM player p LEFT JOIN view.player vp ON vp.player_id = p.id @@ -262,6 +266,11 @@ public function countGuildPlanetsCompleted(string $guild_id): Response } /** + * Roster alpha is a display figure shown beside each member, not a spendable + * balance, so it combines liquid, infused and defusing holdings. `alpha` stays + * in legacy display units for the existing roster screen; `alpha_p` carries the + * same holdings in ualpha for clients that format their own. + * * @param string $guild_id * @return Response * @throws Exception @@ -277,23 +286,29 @@ public function getGuildRoster(string $guild_id): Response p.pfp_client_render_attributes, COALESCE(NULLIF(gu.name, \'\'), gm.name) AS guild_name, gm.tag, - SUM(COALESCE(vpi.balance, 0)) as alpha + COALESCE(vpi.alpha, 0) as alpha, + COALESCE(api.alpha_p, 0) as alpha_p FROM player p LEFT JOIN guild gu ON p.guild_id = gu.id LEFT JOIN guild_meta gm ON gu.id = gm.id - LEFT JOIN view.player_inventory vpi - ON p.id = vpi.player_id + LEFT JOIN ( + SELECT player_id, SUM(balance) AS alpha + FROM view.player_inventory + WHERE denom IN (\'alpha\', \'alpha.infused\', \'alpha.defusing\') + GROUP BY player_id + ) vpi + ON vpi.player_id = p.id + LEFT JOIN ( + SELECT owner_id, SUM(balance) AS alpha_p + FROM structs.api_inventory + WHERE owner_type = \'player\' + AND denom IN (\'ualpha\', \'ualpha.infused\', \'ualpha.defusing\') + GROUP BY owner_id + ) api + ON api.owner_id = p.id WHERE p.guild_id = :guild_id - GROUP BY - p.id, - p.guild_id, - p.username, - p.pfp, - p.pfp_client_render_attributes, - guild_name, - gm.tag ORDER BY username; '; diff --git a/src/src/Manager/InventoryManager.php b/src/src/Manager/InventoryManager.php new file mode 100644 index 000000000..29150ef64 --- /dev/null +++ b/src/src/Manager/InventoryManager.php @@ -0,0 +1,157 @@ +entityManager = $entityManager; + $this->validator = $validator; + $this->constraintViolationUtil = new ConstraintViolationUtil(); + $this->apiRequestParsingManager = new ApiRequestParsingManager( + $this->validator, + $this->constraintViolationUtil + ); + } + + /** + * @throws Exception + */ + public function inventoryByDenom(string $denom, int $page, ?string $limit): Response + { + [$pageLimit, $offset] = $this->limitOffset($page, $limit); + $sql = "SELECT owner_type::text AS owner_type, owner_id, denom, balance::text AS balance + FROM structs.api_inventory + WHERE denom = :denom + ORDER BY balance DESC, owner_type, owner_id + LIMIT {$pageLimit} OFFSET {$offset}"; + $params = [ApiParameters::DENOM => $denom, ApiParameters::PAGE => (string) $page]; + $required = [ApiParameters::DENOM, ApiParameters::PAGE]; + + return $this->queryAllStamped( + $this->entityManager, + $this->apiRequestParsingManager, + $sql, + $params, + $required, + 'inventory' + ); + } + + /** + * @throws Exception + */ + public function inventoryByOwner(string $owner_type, string $owner_id): Response + { + $sql = "SELECT owner_type::text AS owner_type, owner_id, denom, balance::text AS balance + FROM structs.api_inventory + WHERE owner_type = CAST(:owner_type AS structs.object_type) + AND owner_id = :owner + ORDER BY denom"; + $params = [ApiParameters::OWNER_TYPE => $owner_type, ApiParameters::OWNER => $owner_id]; + $required = [ApiParameters::OWNER_TYPE, ApiParameters::OWNER]; + + return $this->queryAllStamped( + $this->entityManager, + $this->apiRequestParsingManager, + $sql, + $params, + $required, + 'inventory' + ); + } + + /** + * @throws Exception + */ + public function getGuildBank(): Response + { + $sql = "SELECT guild_id, denom, collateral::text AS collateral, supply::text AS supply, ratio + FROM structs.api_guild_bank + ORDER BY guild_id, denom"; + + return $this->queryAllStamped( + $this->entityManager, + $this->apiRequestParsingManager, + $sql, + [], + [], + 'guild_bank' + ); + } + + /** + * @throws Exception + */ + public function getGuildBankHistory(string $guild_id, ?string $bucket): Response + { + $responseContent = new ApiResponseContentDto(); + $params = [ApiParameters::GUILD_ID => $guild_id, ApiParameters::BUCKET => $bucket]; + $required = [ApiParameters::GUILD_ID]; + $optional = [ApiParameters::BUCKET]; + $parsedRequest = $this->apiRequestParsingManager->parse($params, $required, $optional); + $responseContent->errors = $parsedRequest->errors; + if (count($responseContent->errors) > 0) { + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + + $trunc = ($bucket === '1h') ? 'hour' : 'day'; + $sql = "SELECT date_trunc('{$trunc}', l.time) AS bucket, + l.action::text AS action, + l.denom, + SUM(CASE WHEN l.direction = 'debit' THEN l.amount_p * -1 ELSE l.amount_p END)::text AS volume + FROM structs.ledger l + WHERE l.action IN ('minted', 'burned', 'infused', 'defusion_completed') + AND ( + l.denom = 'uguild.' || :guild_id + OR l.denom LIKE 'uguild.' || :guild_id || '.%' + ) + AND l.time >= NOW() - INTERVAL '30 days' + GROUP BY bucket, l.action, l.denom + ORDER BY bucket, l.action"; + + $db = $this->entityManager->getConnection(); + $responseContent->data = $db->fetchAllAssociative($sql, ['guild_id' => $guild_id]); + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager); + + return new JsonResponse($responseContent, Response::HTTP_OK); + } + + /** + * @return array{0: int, 1: int} + */ + private function limitOffset(int $page, ?string $limit): array + { + $pageLimit = PaginationLimits::clamp($limit); + $page = max(1, $page); + $offset = ($page - 1) * $pageLimit; + + return [$pageLimit, $offset]; + } +} diff --git a/src/src/Manager/LeaderboardManager.php b/src/src/Manager/LeaderboardManager.php new file mode 100644 index 000000000..16eb2495d --- /dev/null +++ b/src/src/Manager/LeaderboardManager.php @@ -0,0 +1,174 @@ +}> + */ + private const KINDS = [ + 'player' => [ + 'table' => 'structs.api_leaderboard_player', + 'model' => 'leaderboard_player', + 'select' => 'player_id, username, guild_id, + alpha_balance::text AS alpha_balance_p, + alpha_value::text AS alpha_value_p, + (SELECT pd.discord_username + FROM structs.player_discord pd + WHERE pd.player_id = lb.player_id + LIMIT 1) AS discord_username', + 'default_order' => 'alpha_value DESC NULLS LAST, player_id', + 'orders' => [ + 'alpha_value.desc' => 'alpha_value DESC NULLS LAST, player_id', + 'alpha_value.asc' => 'alpha_value ASC NULLS LAST, player_id', + 'alpha_balance.desc' => 'alpha_balance DESC, player_id', + 'alpha_balance.asc' => 'alpha_balance ASC, player_id', + ], + ], + 'guild' => [ + 'table' => 'structs.api_leaderboard_guild', + 'model' => 'leaderboard_guild', + 'select' => 'guild_id, name, onchain_name, player_count, + collateral::text AS collateral_p, + supply::text AS supply_p, + ratio, + member_capacity::text AS member_capacity_p, + member_load::text AS member_load_p, + shared_connection_capacity::text AS shared_connection_capacity_p', + 'default_order' => 'collateral DESC NULLS LAST, guild_id', + 'orders' => [ + 'collateral.desc' => 'collateral DESC NULLS LAST, guild_id', + 'ratio.desc' => 'ratio DESC NULLS LAST, guild_id', + 'player_count.desc' => 'player_count DESC, guild_id', + ], + ], + 'reactor' => [ + 'table' => 'structs.api_leaderboard_reactor', + 'model' => 'leaderboard_reactor', + 'select' => 'reactor_id, fuel::text AS fuel_p, power::text AS power_p', + 'default_order' => 'fuel DESC, reactor_id', + 'orders' => [ + 'fuel.desc' => 'fuel DESC, reactor_id', + 'power.desc' => 'power DESC, reactor_id', + ], + ], + 'substation' => [ + 'table' => 'structs.api_leaderboard_substation', + 'model' => 'leaderboard_substation', + 'select' => 'substation_id, owner, + load::text AS load_p, + member_capacity::text AS member_capacity_p, + shared_connection_capacity::text AS shared_connection_capacity_p, + player_count', + 'default_order' => 'load DESC, substation_id', + 'orders' => [ + 'load.desc' => 'load DESC, substation_id', + 'member_capacity.desc' => 'member_capacity DESC, substation_id', + 'shared_connection_capacity.desc' => 'shared_connection_capacity DESC, substation_id', + 'player_count.desc' => 'player_count DESC, substation_id', + ], + ], + 'provider' => [ + 'table' => 'structs.api_leaderboard_provider', + 'model' => 'leaderboard_provider', + 'select' => 'provider_id, owner, rate_amount::text AS rate_amount_p, rate_denom, agreement_count', + 'default_order' => 'agreement_count DESC, provider_id', + 'orders' => [ + 'agreement_count.desc' => 'agreement_count DESC, provider_id', + 'rate_amount.desc' => 'rate_amount DESC NULLS LAST, provider_id', + ], + ], + ]; + + public EntityManagerInterface $entityManager; + + public ValidatorInterface $validator; + + public ConstraintViolationUtil $constraintViolationUtil; + + public ApiRequestParsingManager $apiRequestParsingManager; + + public function __construct( + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ) { + $this->entityManager = $entityManager; + $this->validator = $validator; + $this->constraintViolationUtil = new ConstraintViolationUtil(); + $this->apiRequestParsingManager = new ApiRequestParsingManager( + $this->validator, + $this->constraintViolationUtil + ); + } + + /** + * @throws Exception + */ + public function getLeaderboard(string $kind, ?string $order, ?string $limit): Response + { + $responseContent = new ApiResponseContentDto(); + $params = [ + ApiParameters::KIND => $kind, + ApiParameters::ORDER => $order, + ApiParameters::LIMIT => $limit, + ]; + $required = [ApiParameters::KIND]; + $optional = [ApiParameters::ORDER, ApiParameters::LIMIT]; + + $parsedRequest = $this->apiRequestParsingManager->parse($params, $required, $optional); + $responseContent->errors = $parsedRequest->errors; + if (count($responseContent->errors) > 0) { + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + + $config = self::KINDS[$kind] ?? null; + if ($config === null) { + $responseContent->errors['kind_invalid'] = 'kind must be player, guild, reactor, substation, or provider'; + + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + + $orderSql = $config['default_order']; + if ($order !== null && $order !== '') { + if (!isset($config['orders'][$order])) { + $responseContent->errors['order_invalid'] = 'order is not allowlisted for this leaderboard'; + + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + $orderSql = $config['orders'][$order]; + } + + $pageLimit = PaginationLimits::clamp($limit, PaginationLimits::LEADERBOARD_DEFAULT); + + $sql = "SELECT {$config['select']} + FROM {$config['table']} lb + ORDER BY {$orderSql} + LIMIT {$pageLimit}"; + + $db = $this->entityManager->getConnection(); + $responseContent->data = $db->fetchAllAssociative($sql); + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager, $config['model']); + + return new JsonResponse($responseContent, Response::HTTP_OK); + } +} diff --git a/src/src/Manager/LedgerManager.php b/src/src/Manager/LedgerManager.php index 9e528bc26..2e8212494 100644 --- a/src/src/Manager/LedgerManager.php +++ b/src/src/Manager/LedgerManager.php @@ -4,10 +4,13 @@ use App\Constant\ApiParameters; use App\Constant\PaginationLimits; +use App\Dto\ApiResponseContentDto; use App\Trait\ApiSqlQueryTrait; use App\Util\ConstraintViolationUtil; +use App\Util\ResponseMetaUtil; use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -160,4 +163,44 @@ public function getTransaction(string $tx_id): Response $requiredFields ); } + + /** + * @throws Exception + */ + public function getLedgerStats(?string $bucket, ?string $denom): Response + { + $responseContent = new ApiResponseContentDto(); + $params = [ApiParameters::BUCKET => $bucket, ApiParameters::DENOM => $denom]; + $parsedRequest = $this->apiRequestParsingManager->parse($params, [], [ApiParameters::BUCKET, ApiParameters::DENOM]); + $responseContent->errors = $parsedRequest->errors; + if (count($responseContent->errors) > 0) { + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + + $trunc = ($bucket === '1h') ? 'hour' : 'day'; + $denomSql = ''; + $queryParams = []; + if ($denom !== null && $denom !== '') { + $denomSql = 'AND denom = :denom'; + $queryParams['denom'] = $denom; + } + + $sql = "SELECT date_trunc('{$trunc}', time) AS bucket, + action::text AS action, + denom, + SUM(amount_p)::text AS volume, + count(*) AS count + FROM structs.ledger + WHERE time >= NOW() - INTERVAL '30 days' + {$denomSql} + GROUP BY bucket, action, denom + ORDER BY bucket, action"; + + $db = $this->entityManager->getConnection(); + $responseContent->data = $db->fetchAllAssociative($sql, $queryParams); + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager); + + return new JsonResponse($responseContent, Response::HTTP_OK); + } } \ No newline at end of file diff --git a/src/src/Manager/PlanetManager.php b/src/src/Manager/PlanetManager.php index ad4dd8ee3..ae01a4b6c 100644 --- a/src/src/Manager/PlanetManager.php +++ b/src/src/Manager/PlanetManager.php @@ -3,6 +3,7 @@ namespace App\Manager; use App\Constant\ApiParameters; +use App\Constant\PaginationLimits; use App\Trait\ApiSqlQueryTrait; use App\Util\ConstraintViolationUtil; use Doctrine\DBAL\Exception; @@ -249,4 +250,72 @@ public function getActivePlanetRaidByFleetId(string $fleet_id): Response $requiredFields ); } + + /** + * @throws Exception + */ + public function planetRaidAll(int $page, ?string $limit): Response + { + return $this->planetRaidPage($page, $limit, null); + } + + /** + * @throws Exception + */ + public function planetRaidByStatus(string $status, int $page, ?string $limit): Response + { + return $this->planetRaidPage($page, $limit, $status); + } + + /** + * @throws Exception + */ + public function countPlanets(): Response + { + return $this->queryOne( + $this->entityManager, + $this->apiRequestParsingManager, + 'SELECT count(*) AS count FROM planet', + [], + [] + ); + } + + /** + * @throws Exception + */ + private function planetRaidPage(int $page, ?string $limit, ?string $status): Response + { + $pageLimit = PaginationLimits::clamp($limit); + $page = max(1, $page); + $offset = ($page - 1) * $pageLimit; + + $statusFilter = ''; + $params = [ApiParameters::PAGE => (string) $page]; + $required = [ApiParameters::PAGE]; + if ($status !== null) { + $statusFilter = 'WHERE pr.status = :status'; + $params[ApiParameters::STATUS] = $status; + $required[] = ApiParameters::STATUS; + } + + $sql = "SELECT + pr.planet_id, + pr.fleet_id, + pr.status, + pr.updated_at, + COALESCE(pr.seized_ore, 0) AS seized_ore + FROM planet_raid pr + {$statusFilter} + ORDER BY pr.updated_at DESC NULLS LAST, pr.planet_id + LIMIT {$pageLimit} OFFSET {$offset}"; + + return $this->queryAllStamped( + $this->entityManager, + $this->apiRequestParsingManager, + $sql, + $params, + $required + ); + } } \ No newline at end of file diff --git a/src/src/Manager/PlayerManager.php b/src/src/Manager/PlayerManager.php index 00c0b09bb..ee7078281 100644 --- a/src/src/Manager/PlayerManager.php +++ b/src/src/Manager/PlayerManager.php @@ -8,6 +8,7 @@ use App\Dto\ApiResponseContentDto; use App\Trait\ApiSqlQueryTrait; use App\Util\ConstraintViolationUtil; +use App\Util\ResponseMetaUtil; use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; @@ -19,6 +20,9 @@ class PlayerManager { use ApiSqlQueryTrait; + /** Lookback, in blocks, for the active-player count when window_blocks is omitted. */ + private const int DEFAULT_ACTIVE_WINDOW_BLOCKS = 16363; + public EntityManagerInterface $entityManager; public ValidatorInterface $validator; @@ -558,4 +562,118 @@ public function getPlayerRaidsLaunched(string $player_id): Response $requiredFields ); } + + /** + * @throws Exception + */ + public function countPlayers(): Response + { + return $this->queryOne( + $this->entityManager, + $this->apiRequestParsingManager, + 'SELECT count(*) AS count FROM player', + [], + [] + ); + } + + /** + * @throws Exception + */ + public function countActivePlayers(?string $windowBlocks): Response + { + $responseContent = new ApiResponseContentDto(); + $params = [ApiParameters::WINDOW_BLOCKS => $windowBlocks]; + $optional = [ApiParameters::WINDOW_BLOCKS]; + $parsedRequest = $this->apiRequestParsingManager->parse($params, [], $optional); + $responseContent->errors = $parsedRequest->errors; + if (count($responseContent->errors) > 0) { + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + + $window = ($windowBlocks === null || $windowBlocks === '') + ? self::DEFAULT_ACTIVE_WINDOW_BLOCKS + : (int) $windowBlocks; + $sql = "SELECT count(*) AS count + FROM grid g + CROSS JOIN current_block cb + WHERE g.attribute_type = 'lastAction' + AND g.object_type = 'player' + AND g.val >= cb.height - :window_blocks"; + $db = $this->entityManager->getConnection(); + $row = $db->fetchAssociative($sql, ['window_blocks' => $window]); + $responseContent->data = $row === false ? ['count' => 0] : $row; + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager); + + return new JsonResponse($responseContent, Response::HTTP_OK); + } + + /** + * @throws Exception + */ + public function getPlayerPower(string $player_id): Response + { + $sql = " + SELECT + vp.player_id, + vp.capacity, + vp.capacity_p, + vp.connection_capacity, + vp.connection_capacity_p, + vp.load, + vp.load_p, + vp.structs_load, + vp.structs_load_p, + vp.total_load, + vp.total_load_p, + vp.total_capacity, + vp.total_capacity_p, + (vp.total_capacity - vp.total_load) AS margin, + (vp.total_capacity_p - vp.total_load_p) AS margin_p, + TRUE AS connection_capacity_is_player_share + FROM view.player vp + WHERE vp.player_id = :player_id + LIMIT 1 + "; + return $this->queryOneStamped( + $this->entityManager, + $this->apiRequestParsingManager, + $sql, + [ApiParameters::PLAYER_ID => $player_id], + [ApiParameters::PLAYER_ID] + ); + } + + /** + * @throws Exception + */ + public function getPlayersAtRisk(?string $limit): Response + { + $pageLimit = PaginationLimits::clamp($limit, 25); + $sql = " + SELECT + vp.player_id, + vp.username, + vp.capacity, + vp.capacity_p, + vp.connection_capacity, + vp.connection_capacity_p, + vp.load, + vp.load_p, + vp.structs_load, + vp.structs_load_p, + vp.total_load, + vp.total_load_p, + vp.total_capacity, + vp.total_capacity_p, + (vp.total_capacity - vp.total_load) AS margin, + (vp.total_capacity_p - vp.total_load_p) AS margin_p, + TRUE AS connection_capacity_is_player_share + FROM view.player vp + ORDER BY margin ASC, vp.player_id + LIMIT {$pageLimit} + "; + return $this->queryAllStamped($this->entityManager, $this->apiRequestParsingManager, $sql, [], []); + } } \ No newline at end of file diff --git a/src/src/Manager/ResolveManager.php b/src/src/Manager/ResolveManager.php new file mode 100644 index 000000000..5a278eb1b --- /dev/null +++ b/src/src/Manager/ResolveManager.php @@ -0,0 +1,300 @@ + */ + private const TYPE_TABLES = [ + 'guild' => 'structs.guild', + 'player' => 'structs.player', + 'planet' => 'structs.planet', + 'reactor' => 'structs.reactor', + 'substation' => 'structs.substation', + 'struct' => 'structs.struct', + 'allocation' => 'structs.allocation', + 'infusion' => 'structs.infusion', + 'fleet' => 'structs.fleet', + 'provider' => 'structs.provider', + 'agreement' => 'structs.agreement', + ]; + + public EntityManagerInterface $entityManager; + + public ValidatorInterface $validator; + + public ConstraintViolationUtil $constraintViolationUtil; + + public ApiRequestParsingManager $apiRequestParsingManager; + + public function __construct( + EntityManagerInterface $entityManager, + ValidatorInterface $validator + ) { + $this->entityManager = $entityManager; + $this->validator = $validator; + $this->constraintViolationUtil = new ConstraintViolationUtil(); + $this->apiRequestParsingManager = new ApiRequestParsingManager( + $this->validator, + $this->constraintViolationUtil + ); + } + + /** + * @throws Exception + */ + public function getDenoms(): Response + { + $responseContent = new ApiResponseContentDto(); + $db = $this->entityManager->getConnection(); + // Every guild has a uguild. denom from the moment it exists; guild_meta + // only adds branding, as a jsonb map of exponent => symbol ({"0": "ugil", "6": "gil"}). + $guilds = $db->fetchAllAssociative( + 'SELECT g.id, gm.denom AS symbols + FROM structs.guild g + LEFT JOIN structs.guild_meta gm ON gm.id = g.id + ORDER BY g.id' + ); + + // Mirrors structs.unit_legacy_format / structs.unit_display_format so clients + // format from this response instead of hardcoding the conversion table. + $units = [ + [ + 'denom' => 'ualpha', + 'display' => 'alpha', + 'exponent' => 6, + 'quantity' => 'mass', + 'scale' => [ + ['exponent' => 0, 'symbol' => 'ug'], + ['exponent' => 3, 'symbol' => 'mg'], + ['exponent' => 6, 'symbol' => 'g'], + ['exponent' => 9, 'symbol' => 'Kg'], + ['exponent' => 18, 'symbol' => 'Tg'], + ], + ], + [ + 'denom' => 'ore', + 'display' => 'ore', + 'exponent' => 0, + 'quantity' => 'mass', + 'scale' => [ + ['exponent' => 0, 'symbol' => 'g'], + ], + ], + ]; + foreach ($guilds as $guild) { + $unit = [ + 'denom' => 'uguild.' . $guild['id'], + 'display' => 'guild.' . $guild['id'], + 'exponent' => 6, + 'quantity' => 'token', + ]; + $symbols = is_string($guild['symbols']) ? json_decode($guild['symbols'], true) : null; + if (is_array($symbols) && $symbols !== []) { + ksort($symbols, SORT_NUMERIC); + $unit['scale'] = []; + foreach ($symbols as $exponent => $symbol) { + $unit['scale'][] = ['exponent' => (int) $exponent, 'symbol' => $symbol]; + } + } + $units[] = $unit; + } + + $responseContent->data = [ + 'amounts' => 'string', + 'counts_and_ratios' => 'number', + 'units' => $units, + // `.infused` / `.defusing` are states of a denom, not denoms of their own. + // Strip a listed suffix, resolve the base denom, keep the suffix as state. + 'state_suffixes' => ['infused', 'defusing'], + // Guild denoms appear as soon as a guild registers one, so a client will + // meet denoms this registry has not caught up with yet. + 'unknown_denom' => [ + 'exponent' => 0, + 'display' => 'raw', + ], + 'bases' => [ + 'energy' => [ + 'denom' => 'milliwatt', + 'display' => 'watt', + 'exponent' => 3, + 'connection_capacity_is_player_share' => true, + ], + ], + // Fields suffixed `_p` carry the base denomination at full precision; + // the unsuffixed field is the truncated display value. + 'precision_suffix' => '_p', + ]; + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager); + + return new JsonResponse($responseContent, Response::HTTP_OK); + } + + /** + * @throws Exception + */ + public function resolve(?string $q): Response + { + $responseContent = new ApiResponseContentDto(); + $parsedRequest = $this->apiRequestParsingManager->parse( + [ApiParameters::Q => $q], + [ApiParameters::Q] + ); + $responseContent->errors = $parsedRequest->errors; + if (count($responseContent->errors) > 0) { + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + + $q = (string) $q; + $db = $this->entityManager->getConnection(); + + if (preg_match(RegexPattern::OBJECT_KEY, $q) === 1) { + $parsed = $this->parseObjectKey($q); + if ($parsed !== null) { + $object = $this->fetchTypedObject($parsed['object_type'], $q); + $responseContent->data = [[ + 'q' => $q, + 'type' => $parsed['object_type'], + 'id' => $q, + 'object' => $object, + 'is_player' => $parsed['object_type'] === 'player', + ]]; + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager); + + return new JsonResponse($responseContent, Response::HTTP_OK); + } + } + + if (strlen($q) >= 32 && preg_match(RegexPattern::ADDRESS, $q) === 1) { + $row = $db->fetchAssociative( + "SELECT pa.address, pa.player_id, pa.status, p.username + FROM structs.player_address pa + LEFT JOIN structs.player p ON p.id = pa.player_id + WHERE pa.address = :q + LIMIT 1", + ['q' => $q] + ); + $responseContent->data = [[ + 'q' => $q, + 'type' => 'address', + 'id' => $q, + 'object' => $row === false ? null : $row, + 'is_player' => is_array($row) && !empty($row['player_id']), + ]]; + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager); + + return new JsonResponse($responseContent, Response::HTTP_OK); + } + + // % and _ are LIKE wildcards; a search for them should match them literally. + $like = '%' . addcslashes($q, '\\%_') . '%'; + $rows = $db->fetchAllAssociative( + "SELECT type, id, name FROM ( + SELECT 'player'::text AS type, p.id, p.username AS name + FROM structs.player p + WHERE p.username ILIKE :q + UNION ALL + SELECT 'guild'::text, g.id, COALESCE(NULLIF(g.name, ''), gm.name) AS name + FROM structs.guild g + LEFT JOIN structs.guild_meta gm ON gm.id = g.id + WHERE g.name ILIKE :q OR gm.name ILIKE :q OR gm.tag ILIKE :q + UNION ALL + SELECT 'substation'::text, s.id, s.name + FROM structs.substation s + WHERE s.name ILIKE :q + ) hits + ORDER BY type, name + LIMIT " . PaginationLimits::BATCH_IDS_MAX, + ['q' => $like] + ); + + $data = []; + foreach ($rows as $row) { + $data[] = [ + 'q' => $q, + 'type' => $row['type'], + 'id' => $row['id'], + 'name' => $row['name'], + 'object' => null, + 'is_player' => $row['type'] === 'player', + ]; + } + $responseContent->data = $data; + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager); + + return new JsonResponse($responseContent, Response::HTTP_OK); + } + + /** + * @throws Exception + */ + public function getObjects(?string $ids): Response + { + $responseContent = new ApiResponseContentDto(); + $parsedRequest = $this->apiRequestParsingManager->parse( + [ApiParameters::IDS => $ids], + [ApiParameters::IDS] + ); + $responseContent->errors = $parsedRequest->errors; + if (count($responseContent->errors) > 0) { + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + + // RegexPattern::IDS already caps the list at BATCH_IDS_MAX entries. + $data = []; + foreach (explode(',', (string) $ids) as $id) { + $parsed = $this->parseObjectKey($id); + $type = $parsed['object_type'] ?? null; + $data[] = [ + 'id' => $id, + 'type' => $type, + 'object' => $type === null ? null : $this->fetchTypedObject($type, $id), + ]; + } + + $responseContent->data = $data; + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager); + + return new JsonResponse($responseContent, Response::HTTP_OK); + } + + /** + * @throws Exception + */ + private function fetchTypedObject(string $type, string $id): ?array + { + // 'address' keys (8-N) have no backing table, so they resolve to null here. + $table = self::TYPE_TABLES[$type] ?? null; + if ($table === null) { + return null; + } + $row = $this->entityManager->getConnection()->fetchAssociative( + "SELECT * FROM {$table} WHERE id = :id LIMIT 1", + ['id' => $id] + ); + + return $row === false ? null : $row; + } +} diff --git a/src/src/Manager/StatReadManager.php b/src/src/Manager/StatReadManager.php index e93aedd4c..3e89f9b74 100644 --- a/src/src/Manager/StatReadManager.php +++ b/src/src/Manager/StatReadManager.php @@ -3,11 +3,13 @@ namespace App\Manager; use App\Constant\ApiParameters; +use App\Constant\ObjectTypes; use App\Constant\PaginationLimits; -use App\Constant\RegexPattern; use App\Dto\ApiResponseContentDto; use App\Trait\ApiSqlQueryTrait; +use App\Trait\ObjectKeyTrait; use App\Util\ConstraintViolationUtil; +use App\Util\ResponseMetaUtil; use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; @@ -17,6 +19,7 @@ class StatReadManager { use ApiSqlQueryTrait; + use ObjectKeyTrait; private const int MAX_RANGE_SECONDS = 604800; @@ -53,26 +56,6 @@ class StatReadManager 'struct_status' => ['struct'], ]; - /** - * Longest-first prefixes so 10 / 11 win over 1. - * - * @var array - */ - private const OBJECT_TYPE_PREFIXES = [ - '10' => 'provider', - '11' => 'agreement', - '0' => 'guild', - '1' => 'player', - '2' => 'planet', - '3' => 'reactor', - '4' => 'substation', - '5' => 'struct', - '6' => 'allocation', - '7' => 'infusion', - '8' => 'address', - '9' => 'fleet', - ]; - public EntityManagerInterface $entityManager; public ValidatorInterface $validator; @@ -94,32 +77,6 @@ public function __construct( ); } - /** - * @return array{object_type: string, object_index: int}|null - */ - private function parseObjectKey(string $object_key): ?array - { - if (!preg_match(RegexPattern::OBJECT_KEY, $object_key)) { - return null; - } - foreach (array_keys(self::OBJECT_TYPE_PREFIXES) as $prefix) { - $sep = $prefix . '-'; - if (str_starts_with($object_key, $sep)) { - $indexPart = substr($object_key, strlen($sep)); - if ($indexPart === '' || !ctype_digit($indexPart)) { - return null; - } - - return [ - 'object_type' => self::OBJECT_TYPE_PREFIXES[$prefix], - 'object_index' => (int) $indexPart, - ]; - } - } - - return null; - } - /** * @throws Exception */ @@ -128,7 +85,9 @@ public function getStatRange( string $object_key, int $page, string $start_time, - string $end_time + string $end_time, + ?string $bucket = null, + ?string $limitParam = null ): Response { $responseContent = new ApiResponseContentDto(); @@ -138,6 +97,8 @@ public function getStatRange( ApiParameters::PAGE => (string) $page, ApiParameters::START_TIME => $start_time, ApiParameters::END_TIME => $end_time, + ApiParameters::BUCKET => $bucket, + ApiParameters::LIMIT => $limitParam, ]; $required = [ ApiParameters::METRIC, @@ -147,7 +108,11 @@ public function getStatRange( ApiParameters::END_TIME, ]; - $parsedRequest = $this->apiRequestParsingManager->parse($requestParams, $required); + $parsedRequest = $this->apiRequestParsingManager->parse( + $requestParams, + $required, + [ApiParameters::BUCKET, ApiParameters::LIMIT] + ); $responseContent->errors = $parsedRequest->errors; if (count($responseContent->errors) > 0) { return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); @@ -160,7 +125,10 @@ public function getStatRange( return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); } - if ($end - $start > self::MAX_RANGE_SECONDS) { + $maxRange = ($bucket !== null && $bucket !== '') + ? PaginationLimits::AGGREGATE_MAX_SECONDS + : self::MAX_RANGE_SECONDS; + if ($end - $start > $maxRange) { $responseContent->errors['time_range_too_large'] = 'Time range exceeds maximum allowed window'; return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); @@ -174,12 +142,17 @@ public function getStatRange( } $isFamilyTwo = isset(self::FAMILY_TWO_TABLES[$metric]); + if (!$isFamilyTwo && !isset(self::FAMILY_ONE_TABLES[$metric])) { + $responseContent->errors['metric_invalid'] = 'Unknown metric'; + + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } if ($isFamilyTwo) { $allowed = self::FAMILY_TWO_OBJECT_TYPES[$metric] ?? []; if (!in_array($parsed['object_type'], $allowed, true)) { $expected = []; foreach ($allowed as $type) { - $prefix = array_search($type, self::OBJECT_TYPE_PREFIXES, true); + $prefix = array_search($type, ObjectTypes::PREFIXES, true); $expected[] = $prefix === false ? $type : "{$type} ({$prefix}-{index})"; } $hint = $expected === [] ? 'no object types are configured for this metric' : implode(' or ', $expected); @@ -189,18 +162,26 @@ public function getStatRange( } } - $limit = PaginationLimits::DEFAULT; + $limit = PaginationLimits::clamp($limitParam); $page = max(1, $page); $offset = ($page - 1) * $limit; $table = $isFamilyTwo ? self::FAMILY_TWO_TABLES[$metric] : self::FAMILY_ONE_TABLES[$metric]; + $select = 'time, value'; + $group = ''; + if ($bucket === '1h' || $bucket === '1d') { + $trunc = $bucket === '1h' ? 'hour' : 'day'; + $select = "date_trunc('{$trunc}', time) AS time, AVG(value) AS value"; + $group = 'GROUP BY 1'; + } if ($isFamilyTwo) { - $sql = "SELECT time, value + $sql = "SELECT {$select} FROM {$table} WHERE object_index = :object_index AND time >= to_timestamp(:start_ts) AND time < to_timestamp(:end_ts) + {$group} ORDER BY time LIMIT {$limit} OFFSET {$offset}"; $params = [ @@ -209,12 +190,13 @@ public function getStatRange( 'end_ts' => $end, ]; } else { - $sql = "SELECT time, value + $sql = "SELECT {$select} FROM {$table} WHERE object_type = CAST(:object_type AS structs.object_type) AND object_index = :object_index AND time >= to_timestamp(:start_ts) AND time < to_timestamp(:end_ts) + {$group} ORDER BY time LIMIT {$limit} OFFSET {$offset}"; $params = [ @@ -232,4 +214,169 @@ public function getStatRange( return new JsonResponse($responseContent, Response::HTTP_OK); } + + /** + * @throws Exception + */ + public function getStatAggregate( + string $metric, + string $object_type, + string $start_time, + string $end_time, + ?string $bucket + ): Response { + $responseContent = new ApiResponseContentDto(); + $requestParams = [ + ApiParameters::METRIC => $metric, + ApiParameters::OBJECT_TYPE => $object_type, + ApiParameters::START_TIME => $start_time, + ApiParameters::END_TIME => $end_time, + ApiParameters::BUCKET => $bucket, + ]; + $parsedRequest = $this->apiRequestParsingManager->parse( + $requestParams, + [ApiParameters::METRIC, ApiParameters::OBJECT_TYPE, ApiParameters::START_TIME, ApiParameters::END_TIME], + [ApiParameters::BUCKET] + ); + $responseContent->errors = $parsedRequest->errors; + if (count($responseContent->errors) > 0) { + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + + $start = (int) $start_time; + $end = (int) $end_time; + if ($end <= $start) { + $responseContent->errors['time_range_invalid'] = 'end_time must be greater than start_time'; + + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + if ($end - $start > PaginationLimits::AGGREGATE_MAX_SECONDS) { + $responseContent->errors['time_range_too_large'] = 'Time range exceeds maximum allowed window'; + + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + + $isFamilyTwo = isset(self::FAMILY_TWO_TABLES[$metric]); + if (!$isFamilyTwo && !isset(self::FAMILY_ONE_TABLES[$metric])) { + $responseContent->errors['metric_invalid'] = 'Unknown metric'; + + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + if ($isFamilyTwo) { + $allowed = self::FAMILY_TWO_OBJECT_TYPES[$metric] ?? []; + if (!in_array($object_type, $allowed, true)) { + $responseContent->errors['object_type_invalid'] = "metric '{$metric}' does not support object_type '{$object_type}'"; + + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + } + + $trunc = ($bucket === '1d') ? 'day' : 'hour'; + $interval = ($bucket === '1d') ? '1 day' : '1 hour'; + $table = $isFamilyTwo ? self::FAMILY_TWO_TABLES[$metric] : self::FAMILY_ONE_TABLES[$metric]; + $typeFilter = $isFamilyTwo + ? '' + : 'AND s.object_type = CAST(:object_type AS structs.object_type)'; + + // Samples are change-triggered: an object reports only when its value moves, + // so a plain per-bucket aggregate would describe only the objects that + // happened to move. Each bucket instead reports the last-known value of every + // object as of the bucket's close (LOCF), carried forward from its most recent + // sample. Objects with no sample yet contribute nothing rather than zero. + // + // Rather than joining every bucket against the table, the running total is + // rebuilt from per-object deltas: `seed` collapses all history before the + // first bucket closes into one row per object, and `in_range` reads only the + // requested window, so the scan is bounded by the range, not the table. + $sql = "WITH bounds AS ( + SELECT + date_trunc('{$trunc}', to_timestamp(:start_ts)) AS b0, + date_trunc('{$trunc}', to_timestamp(:end_ts)) AS bn, + CAST(:bucket_interval AS interval) AS step + ), + buckets AS ( + SELECT generate_series(b0, bn, step) AS bucket FROM bounds + ), + seed AS ( + SELECT DISTINCT ON (s.object_index) + (SELECT b0 FROM bounds) AS bucket, + s.object_index, + s.value + FROM {$table} s + WHERE s.time < (SELECT b0 + step FROM bounds) + {$typeFilter} + ORDER BY s.object_index, s.time DESC + ), + in_range AS ( + SELECT DISTINCT ON (date_trunc('{$trunc}', s.time), s.object_index) + date_trunc('{$trunc}', s.time) AS bucket, + s.object_index, + s.value + FROM {$table} s + WHERE s.time >= (SELECT b0 + step FROM bounds) + AND s.time < (SELECT bn + step FROM bounds) + {$typeFilter} + ORDER BY date_trunc('{$trunc}', s.time), s.object_index, s.time DESC + ), + observations AS ( + SELECT * FROM seed + UNION ALL + SELECT * FROM in_range + ), + deltas AS ( + SELECT + bucket, + value - COALESCE(lag(value) OVER w, 0) AS delta, + CASE WHEN lag(value) OVER w IS NULL THEN 1 ELSE 0 END AS is_new + FROM observations + WINDOW w AS (PARTITION BY object_index ORDER BY bucket) + ), + per_bucket AS ( + SELECT bucket, SUM(delta) AS delta_sum, SUM(is_new) AS new_objects + FROM deltas + GROUP BY bucket + ), + sample_counts AS ( + SELECT date_trunc('{$trunc}', s.time) AS bucket, count(*) AS samples + FROM {$table} s + WHERE s.time >= (SELECT b0 FROM bounds) + AND s.time < (SELECT bn + step FROM bounds) + {$typeFilter} + GROUP BY 1 + ), + rolled AS ( + SELECT + b.bucket, + SUM(COALESCE(pb.delta_sum, 0)) OVER (ORDER BY b.bucket) AS total, + SUM(COALESCE(pb.new_objects, 0)) OVER (ORDER BY b.bucket) AS population, + COALESCE(sc.samples, 0) AS samples + FROM buckets b + LEFT JOIN per_bucket pb ON pb.bucket = b.bucket + LEFT JOIN sample_counts sc ON sc.bucket = b.bucket + ) + SELECT + bucket, + CASE WHEN population > 0 THEN total END AS sum, + CASE WHEN population > 0 THEN total / population END AS avg, + population, + samples + FROM rolled + ORDER BY bucket"; + + $params = [ + 'start_ts' => $start, + 'end_ts' => $end, + 'bucket_interval' => $interval, + ]; + if (!$isFamilyTwo) { + $params['object_type'] = $object_type; + } + + $db = $this->entityManager->getConnection(); + $responseContent->data = $db->fetchAllAssociative($sql, $params); + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager); + + return new JsonResponse($responseContent, Response::HTTP_OK); + } } diff --git a/src/src/Manager/StructManager.php b/src/src/Manager/StructManager.php index 2fae19089..75fa57ee6 100644 --- a/src/src/Manager/StructManager.php +++ b/src/src/Manager/StructManager.php @@ -227,4 +227,55 @@ public function getAllStructTypes(): Response [] ); } + + /** + * @throws Exception + */ + public function getStructStatusCounts(): Response + { + $query = ' + SELECT + count(*) FILTER (WHERE materialized) AS materialized, + count(*) FILTER (WHERE built) AS built, + count(*) FILTER (WHERE online) AS online, + count(*) FILTER (WHERE stored) AS stored, + count(*) FILTER (WHERE hidden) AS hidden, + count(*) FILTER (WHERE destroyed) AS destroyed, + count(*) FILTER (WHERE locked) AS locked, + count(*) AS total + FROM view.struct_status + '; + + return $this->queryOne( + $this->entityManager, + $this->apiRequestParsingManager, + $query, + [], + [] + ); + } + + /** + * @throws Exception + */ + public function countStructs(?string $isDestroyed): Response + { + if ($isDestroyed === '0' || $isDestroyed === '1') { + return $this->queryOne( + $this->entityManager, + $this->apiRequestParsingManager, + 'SELECT count(*) AS count FROM struct WHERE is_destroyed = (:is_destroyed = \'1\')', + [ApiParameters::IS_DESTROYED => $isDestroyed], + [ApiParameters::IS_DESTROYED] + ); + } + + return $this->queryOne( + $this->entityManager, + $this->apiRequestParsingManager, + 'SELECT count(*) AS count FROM struct', + [], + [] + ); + } } \ No newline at end of file diff --git a/src/src/Manager/TableReadManager.php b/src/src/Manager/TableReadManager.php index 4f22d9b0d..5e4a2476b 100644 --- a/src/src/Manager/TableReadManager.php +++ b/src/src/Manager/TableReadManager.php @@ -4,10 +4,14 @@ use App\Constant\ApiParameters; use App\Constant\PaginationLimits; +use App\Dto\ApiResponseContentDto; use App\Trait\ApiSqlQueryTrait; use App\Util\ConstraintViolationUtil; +use App\Util\ResponseMetaUtil; use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -25,6 +29,21 @@ class TableReadManager { use ApiSqlQueryTrait; + /** Ordered by (updated_at, id): supports updated_since and the after_id cursor. */ + private const string KEYSET_UPDATED_AT_ID = 'updated_at_id'; + + /** Has updated_at but no single-column id, so updated_since only, no cursor. */ + private const string FILTER_UPDATED_AT = 'updated_at'; + + /** + * Per-planet feed ordered by (time, seq). seq is a counter per planet, so it + * only works as a cursor once the list is already filtered to one planet. + * Supports since_seq only. + */ + private const string KEYSET_SEQ = 'seq'; + + private const string GRID_DEFAULT_ORDER = 'updated_at DESC NULLS LAST, id'; + public EntityManagerInterface $entityManager; public ValidatorInterface $validator; @@ -33,6 +52,27 @@ class TableReadManager public ApiRequestParsingManager $apiRequestParsingManager; + private int $pageLimit = PaginationLimits::DEFAULT; + + private ?string $updatedSince = null; + + private ?string $isDestroyed = null; + + private bool $includeTotal = false; + + private bool $includeMeta = false; + + private ?string $order = null; + + private ?string $sinceSeq = null; + + private ?string $afterId = null; + + /** Set by the endpoints that consume order / is_destroyed, so the rest can reject them. */ + private bool $orderApplied = false; + + private bool $isDestroyedApplied = false; + public function __construct( EntityManagerInterface $entityManager, ValidatorInterface $validator @@ -46,74 +86,205 @@ public function __construct( ); } + public function applyListQuery(Request $request): self + { + $this->pageLimit = PaginationLimits::clamp($request->query->get(ApiParameters::LIMIT)); + $updatedSince = $request->query->get(ApiParameters::UPDATED_SINCE); + if ($updatedSince !== null && $updatedSince !== '') { + $this->updatedSince = (string) $updatedSince; + } + $isDestroyed = $request->query->get(ApiParameters::IS_DESTROYED); + if ($isDestroyed === '0' || $isDestroyed === '1') { + $this->isDestroyed = $isDestroyed; + } + $this->includeTotal = $request->query->get(ApiParameters::INCLUDE_TOTAL) === '1'; + $this->includeMeta = $request->query->get(ApiParameters::INCLUDE_META) === '1'; + $order = $request->query->get(ApiParameters::ORDER); + if ($order !== null && $order !== '') { + $this->order = (string) $order; + } + $sinceSeq = $request->query->get(ApiParameters::SINCE_SEQ); + if ($sinceSeq !== null && $sinceSeq !== '') { + $this->sinceSeq = (string) $sinceSeq; + } + $afterId = $request->query->get(ApiParameters::AFTER_ID); + if ($afterId !== null && $afterId !== '') { + $this->afterId = (string) $afterId; + } + + return $this; + } + /** - * @return array{0: int, 1: int} + * Composes a paged list response from its parts. Cursor and filter support is + * declared per endpoint through $keyset rather than inferred from the finished + * SQL, so a cursor param sent to an endpoint that cannot honour it is rejected + * instead of being silently ignored. + * + * @param string[] $filters bare predicates, ANDed together + * @param null|callable(array): array $rowMapper decorates each returned row + * @throws Exception */ - private function limitOffset(int $page): array + private function listQuery( + string $select, + string $from, + array $filters, + string $orderBy, + array $params, + array $required, + int $page, + ?string $keyset = self::KEYSET_UPDATED_AT_ID, + ?callable $rowMapper = null + ): Response { + $supportsUpdatedSince = $keyset === self::KEYSET_UPDATED_AT_ID + || $keyset === self::FILTER_UPDATED_AT; + + if ($this->updatedSince !== null && !$supportsUpdatedSince) { + return $this->unsupportedListParam(ApiParameters::UPDATED_SINCE); + } + if ($this->sinceSeq !== null && $keyset !== self::KEYSET_SEQ) { + return $this->unsupportedListParam(ApiParameters::SINCE_SEQ); + } + if ($this->afterId !== null && $keyset !== self::KEYSET_UPDATED_AT_ID) { + return $this->unsupportedListParam(ApiParameters::AFTER_ID); + } + if ($this->order !== null && !$this->orderApplied) { + return $this->unsupportedListParam(ApiParameters::ORDER); + } + if ($this->isDestroyed !== null && !$this->isDestroyedApplied) { + return $this->unsupportedListParam(ApiParameters::IS_DESTROYED); + } + + if ($this->updatedSince !== null) { + $filters[] = 'updated_at > to_timestamp(:updated_since)'; + $params[ApiParameters::UPDATED_SINCE] = $this->updatedSince; + $required[] = ApiParameters::UPDATED_SINCE; + } + + // A cursor replaces the offset; mixing the two would skip rows. + $cursor = false; + if ($keyset === self::KEYSET_SEQ && $this->sinceSeq !== null) { + $filters[] = 'seq > :since_seq'; + $params[ApiParameters::SINCE_SEQ] = $this->sinceSeq; + $required[] = ApiParameters::SINCE_SEQ; + $cursor = true; + } elseif ($this->afterId !== null) { + // The page order is updated_at DESC NULLS LAST, id ASC, so the rows after + // the cursor are the older ones, plus same-timestamp rows with a greater id. + // NULL updated_at sorts last, which '-infinity' reproduces on both sides. + $cursorTs = "(SELECT COALESCE(k.updated_at, '-infinity') FROM {$from} k WHERE k.id = :after_id)"; + $filters[] = "(COALESCE(updated_at, '-infinity') < {$cursorTs}" + . " OR (COALESCE(updated_at, '-infinity') = {$cursorTs} AND id > :after_id))"; + $params[ApiParameters::AFTER_ID] = $this->afterId; + $required[] = ApiParameters::AFTER_ID; + $cursor = true; + } + + $where = $filters === [] ? '' : 'WHERE ' . implode(' AND ', $filters); + $offset = (max(1, $page) - 1) * $this->pageLimit; + $paging = $cursor ? "LIMIT {$this->pageLimit}" : "LIMIT {$this->pageLimit} OFFSET {$offset}"; + + $sql = "SELECT {$select} + FROM {$from} + {$where} + ORDER BY {$orderBy} + {$paging}"; + + [$responseContent, $status] = $this->runQuery( + $this->entityManager, + $this->apiRequestParsingManager, + $sql, + $params, + $required, + true + ); + + if ($status === Response::HTTP_OK && $rowMapper !== null && is_array($responseContent->data)) { + $responseContent->data = array_map($rowMapper, $responseContent->data); + } + if ($status === Response::HTTP_OK && $this->includeTotal) { + // runQuery already validated $params, so the bound values can be reused as-is. + $responseContent->total = $this->entityManager->getConnection()->fetchOne( + "SELECT count(*) FROM {$from} {$where}", + array_intersect_key($params, array_flip($required)) + ); + } + if ($status === Response::HTTP_OK && $this->includeMeta) { + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager); + } + + return new JsonResponse($responseContent, $status); + } + + private function unsupportedListParam(string $param): Response { - $limit = PaginationLimits::DEFAULT; - $page = max(1, $page); - $offset = ($page - 1) * $limit; + $responseContent = new ApiResponseContentDto(); + $responseContent->errors["{$param}_unsupported"] = "{$param} is not supported by this endpoint"; - return [$limit, $offset]; + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); } + // --- address_tag --- public function addressTagAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT address, label, entry, created_at, updated_at - FROM structs.address_tag - ORDER BY updated_at DESC NULLS LAST, address, label - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'address, label, entry, created_at, updated_at', + 'structs.address_tag', + [], + 'updated_at DESC NULLS LAST, address, label', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } public function addressTagByAddress(string $address, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT address, label, entry, created_at, updated_at - FROM structs.address_tag - WHERE address = :address - ORDER BY updated_at DESC NULLS LAST, label - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::ADDRESS => $address, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::ADDRESS, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'address, label, entry, created_at, updated_at', + 'structs.address_tag', + [ + 'address = :address', + ], + 'updated_at DESC NULLS LAST, label', + [ApiParameters::ADDRESS => $address, ApiParameters::PAGE => (string) $page], + [ApiParameters::ADDRESS, ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } // --- agreement --- public function agreementAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, provider_id, allocation_id, capacity, start_block, end_block, creator, owner, created_at, updated_at - FROM structs.agreement - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, provider_id, allocation_id, capacity, start_block, end_block, creator, owner, created_at, updated_at', + 'structs.agreement', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function agreementByProvider(string $provider_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, provider_id, allocation_id, capacity, start_block, end_block, creator, owner, created_at, updated_at - FROM structs.agreement - WHERE provider_id = :provider_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PROVIDER_ID => $provider_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PROVIDER_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, provider_id, allocation_id, capacity, start_block, end_block, creator, owner, created_at, updated_at', + 'structs.agreement', + [ + 'provider_id = :provider_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PROVIDER_ID => $provider_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::PROVIDER_ID, ApiParameters::PAGE], + $page, + ); } public function agreementByAllocation(string $allocation_id): Response @@ -156,71 +327,75 @@ public function agreementByOwner(string $owner): Response public function allocationAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, allocation_type, source_id, index, destination_id, creator, controller, created_at, updated_at - FROM structs.allocation - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, allocation_type, source_id, index, destination_id, creator, controller, created_at, updated_at', + 'structs.allocation', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function allocationBySource(string $source_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, allocation_type, source_id, index, destination_id, creator, controller, created_at, updated_at - FROM structs.allocation - WHERE source_id = :source_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::SOURCE_ID => $source_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::SOURCE_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, allocation_type, source_id, index, destination_id, creator, controller, created_at, updated_at', + 'structs.allocation', + [ + 'source_id = :source_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::SOURCE_ID => $source_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::SOURCE_ID, ApiParameters::PAGE], + $page, + ); } public function allocationByDestination(string $destination_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, allocation_type, source_id, index, destination_id, creator, controller, created_at, updated_at - FROM structs.allocation - WHERE destination_id = :destination_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::DESTINATION_ID => $destination_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::DESTINATION_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, allocation_type, source_id, index, destination_id, creator, controller, created_at, updated_at', + 'structs.allocation', + [ + 'destination_id = :destination_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::DESTINATION_ID => $destination_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::DESTINATION_ID, ApiParameters::PAGE], + $page, + ); } public function allocationByCreator(string $creator, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, allocation_type, source_id, index, destination_id, creator, controller, created_at, updated_at - FROM structs.allocation - WHERE creator = :creator - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::CREATOR => $creator, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::CREATOR, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, allocation_type, source_id, index, destination_id, creator, controller, created_at, updated_at', + 'structs.allocation', + [ + 'creator = :creator', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::CREATOR => $creator, ApiParameters::PAGE => (string) $page], + [ApiParameters::CREATOR, ApiParameters::PAGE], + $page, + ); } public function allocationByController(string $controller, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, allocation_type, source_id, index, destination_id, creator, controller, created_at, updated_at - FROM structs.allocation - WHERE controller = :controller - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::CONTROLLER => $controller, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::CONTROLLER, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, allocation_type, source_id, index, destination_id, creator, controller, created_at, updated_at', + 'structs.allocation', + [ + 'controller = :controller', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::CONTROLLER => $controller, ApiParameters::PAGE => (string) $page], + [ApiParameters::CONTROLLER, ApiParameters::PAGE], + $page, + ); } // --- banned_word --- @@ -238,787 +413,892 @@ public function bannedWordAll(): Response public function defusionAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT validator_address, delegator_address, defusion_type, amount_p, amount, denom, completed_at, created_at - FROM structs.defusion - ORDER BY created_at DESC NULLS LAST, validator_address, delegator_address - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'validator_address, delegator_address, defusion_type, amount_p, amount, denom, completed_at, created_at', + 'structs.defusion', + [], + 'created_at DESC NULLS LAST, validator_address, delegator_address', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + null, + ); } public function defusionByValidator(string $validator_address, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT validator_address, delegator_address, defusion_type, amount_p, amount, denom, completed_at, created_at - FROM structs.defusion - WHERE validator_address = :validator_address - ORDER BY created_at DESC NULLS LAST, delegator_address - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::VALIDATOR_ADDRESS => $validator_address, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::VALIDATOR_ADDRESS, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'validator_address, delegator_address, defusion_type, amount_p, amount, denom, completed_at, created_at', + 'structs.defusion', + [ + 'validator_address = :validator_address', + ], + 'created_at DESC NULLS LAST, delegator_address', + [ApiParameters::VALIDATOR_ADDRESS => $validator_address, ApiParameters::PAGE => (string) $page], + [ApiParameters::VALIDATOR_ADDRESS, ApiParameters::PAGE], + $page, + null, + ); } public function defusionByDelegator(string $delegator_address, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT validator_address, delegator_address, defusion_type, amount_p, amount, denom, completed_at, created_at - FROM structs.defusion - WHERE delegator_address = :delegator_address - ORDER BY created_at DESC NULLS LAST, validator_address - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::DELEGATOR_ADDRESS => $delegator_address, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::DELEGATOR_ADDRESS, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'validator_address, delegator_address, defusion_type, amount_p, amount, denom, completed_at, created_at', + 'structs.defusion', + [ + 'delegator_address = :delegator_address', + ], + 'created_at DESC NULLS LAST, validator_address', + [ApiParameters::DELEGATOR_ADDRESS => $delegator_address, ApiParameters::PAGE => (string) $page], + [ApiParameters::DELEGATOR_ADDRESS, ApiParameters::PAGE], + $page, + null, + ); } // --- fleet --- public function fleetAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, owner, map, space_slots, air_slots, land_slots, water_slots, location_type, location_id, status, - location_list_forward, location_list_backward, command_struct, created_at, updated_at - FROM structs.fleet - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, owner, map, space_slots, air_slots, land_slots, water_slots, location_type, location_id, status, location_list_forward, location_list_backward, command_struct, created_at, updated_at', + 'structs.fleet', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function fleetByLocation(string $location_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, owner, map, space_slots, air_slots, land_slots, water_slots, location_type, location_id, status, - location_list_forward, location_list_backward, command_struct, created_at, updated_at - FROM structs.fleet - WHERE location_id = :location_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::LOCATION_ID => $location_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::LOCATION_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, owner, map, space_slots, air_slots, land_slots, water_slots, location_type, location_id, status, location_list_forward, location_list_backward, command_struct, created_at, updated_at', + 'structs.fleet', + [ + 'location_id = :location_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::LOCATION_ID => $location_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::LOCATION_ID, ApiParameters::PAGE], + $page, + ); } // --- grid --- public function gridAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, attribute_type, object_type, object_index, object_id, val, updated_at - FROM structs.grid - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, attribute_type, object_type, object_index, object_id, val, updated_at', + 'structs.grid', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function gridByObject(string $object_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, attribute_type, object_type, object_index, object_id, val, updated_at - FROM structs.grid - WHERE object_id = :object_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::OBJECT_ID => $object_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::OBJECT_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, attribute_type, object_type, object_index, object_id, val, updated_at', + 'structs.grid', + [ + 'object_id = :object_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::OBJECT_ID => $object_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::OBJECT_ID, ApiParameters::PAGE], + $page, + ); } public function gridByAttributeType(string $attribute_type, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, attribute_type, object_type, object_index, object_id, val, updated_at - FROM structs.grid - WHERE attribute_type = :attribute_type - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; + return $this->gridByAttributeTypeFiltered($attribute_type, $page, null); + } + + public function gridByAttributeTypeAndObjectType(string $attribute_type, string $object_type, int $page): Response + { + return $this->gridByAttributeTypeFiltered($attribute_type, $page, $object_type); + } + + private function gridByAttributeTypeFiltered(string $attribute_type, int $page, ?string $object_type): Response + { + $filters = ['attribute_type = :attribute_type']; $params = [ApiParameters::ATTRIBUTE_TYPE => $attribute_type, ApiParameters::PAGE => (string) $page]; $required = [ApiParameters::ATTRIBUTE_TYPE, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + if ($object_type !== null) { + // grid.object_type is plain varchar, not the structs.object_type enum. + $filters[] = 'object_type = :object_type'; + $params[ApiParameters::OBJECT_TYPE] = $object_type; + $required[] = ApiParameters::OBJECT_TYPE; + } + + $orderSql = $this->gridOrderSql(); + if ($orderSql === null) { + $responseContent = new ApiResponseContentDto(); + $responseContent->errors['order_invalid'] = 'order is not allowlisted for grid reads'; + + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + + // The after_id cursor is a (updated_at, id) tuple, so it only holds while the + // caller is on the default ordering. + $keyset = $orderSql === self::GRID_DEFAULT_ORDER + ? self::KEYSET_UPDATED_AT_ID + : self::FILTER_UPDATED_AT; + + return $this->listQuery( + 'id, attribute_type, object_type, object_index, object_id, val, updated_at', + 'structs.grid', + $filters, + $orderSql, + $params, + $required, + $page, + $keyset, + ); } // --- guild (list routes only; single guild still on GuildController) --- public function guildAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, endpoint, join_infusion_minimum, join_infusion_minimum_p, join_infusion_minimum_bypass_by_request, - join_infusion_minimum_bypass_by_invite, primary_reactor_id, entry_substation_id, entry_rank, creator, owner, created_at, updated_at - FROM structs.guild - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, index, endpoint, join_infusion_minimum, join_infusion_minimum_p, join_infusion_minimum_bypass_by_request, join_infusion_minimum_bypass_by_invite, primary_reactor_id, entry_substation_id, entry_rank, creator, owner, created_at, updated_at', + 'structs.guild', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function guildByPrimaryReactor(string $primary_reactor_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, endpoint, join_infusion_minimum, join_infusion_minimum_p, join_infusion_minimum_bypass_by_request, - join_infusion_minimum_bypass_by_invite, primary_reactor_id, entry_substation_id, entry_rank, creator, owner, created_at, updated_at - FROM structs.guild - WHERE primary_reactor_id = :primary_reactor_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PRIMARY_REACTOR_ID => $primary_reactor_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PRIMARY_REACTOR_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, index, endpoint, join_infusion_minimum, join_infusion_minimum_p, join_infusion_minimum_bypass_by_request, join_infusion_minimum_bypass_by_invite, primary_reactor_id, entry_substation_id, entry_rank, creator, owner, created_at, updated_at', + 'structs.guild', + [ + 'primary_reactor_id = :primary_reactor_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PRIMARY_REACTOR_ID => $primary_reactor_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::PRIMARY_REACTOR_ID, ApiParameters::PAGE], + $page, + ); } public function guildByEntrySubstation(string $entry_substation_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, endpoint, join_infusion_minimum, join_infusion_minimum_p, join_infusion_minimum_bypass_by_request, - join_infusion_minimum_bypass_by_invite, primary_reactor_id, entry_substation_id, entry_rank, creator, owner, created_at, updated_at - FROM structs.guild - WHERE entry_substation_id = :entry_substation_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::ENTRY_SUBSTATION_ID => $entry_substation_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::ENTRY_SUBSTATION_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, index, endpoint, join_infusion_minimum, join_infusion_minimum_p, join_infusion_minimum_bypass_by_request, join_infusion_minimum_bypass_by_invite, primary_reactor_id, entry_substation_id, entry_rank, creator, owner, created_at, updated_at', + 'structs.guild', + [ + 'entry_substation_id = :entry_substation_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::ENTRY_SUBSTATION_ID => $entry_substation_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::ENTRY_SUBSTATION_ID, ApiParameters::PAGE], + $page, + ); } public function guildByOwner(string $owner, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, endpoint, join_infusion_minimum, join_infusion_minimum_p, join_infusion_minimum_bypass_by_request, - join_infusion_minimum_bypass_by_invite, primary_reactor_id, entry_substation_id, entry_rank, creator, owner, created_at, updated_at - FROM structs.guild - WHERE owner = :owner - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::OWNER => $owner, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::OWNER, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, index, endpoint, join_infusion_minimum, join_infusion_minimum_p, join_infusion_minimum_bypass_by_request, join_infusion_minimum_bypass_by_invite, primary_reactor_id, entry_substation_id, entry_rank, creator, owner, created_at, updated_at', + 'structs.guild', + [ + 'owner = :owner', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::OWNER => $owner, ApiParameters::PAGE => (string) $page], + [ApiParameters::OWNER, ApiParameters::PAGE], + $page, + ); } - // --- guild_membership_application --- - public function guildMembershipApplicationAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT guild_id, player_id, join_type, status, proposer, substation_id, created_at, updated_at - FROM structs.guild_membership_application - ORDER BY updated_at DESC NULLS LAST, guild_id, player_id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'guild_id, player_id, join_type, status, proposer, substation_id, created_at, updated_at', + 'structs.guild_membership_application', + [], + 'updated_at DESC NULLS LAST, guild_id, player_id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } public function guildMembershipApplicationByGuild(string $guild_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT guild_id, player_id, join_type, status, proposer, substation_id, created_at, updated_at - FROM structs.guild_membership_application - WHERE guild_id = :guild_id - ORDER BY updated_at DESC NULLS LAST, player_id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::GUILD_ID => $guild_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::GUILD_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'guild_id, player_id, join_type, status, proposer, substation_id, created_at, updated_at', + 'structs.guild_membership_application', + [ + 'guild_id = :guild_id', + ], + 'updated_at DESC NULLS LAST, player_id', + [ApiParameters::GUILD_ID => $guild_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::GUILD_ID, ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } public function guildMembershipApplicationByPlayer(string $player_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT guild_id, player_id, join_type, status, proposer, substation_id, created_at, updated_at - FROM structs.guild_membership_application - WHERE player_id = :player_id - ORDER BY updated_at DESC NULLS LAST, guild_id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PLAYER_ID => $player_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PLAYER_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'guild_id, player_id, join_type, status, proposer, substation_id, created_at, updated_at', + 'structs.guild_membership_application', + [ + 'player_id = :player_id', + ], + 'updated_at DESC NULLS LAST, guild_id', + [ApiParameters::PLAYER_ID => $player_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::PLAYER_ID, ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } // --- infusion (raw rows; distinct from InfusionController guild join) --- public function infusionAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT destination_id, address, destination_type, player_id, - fuel, fuel_p, defusing, defusing_p, power, power_p, ratio, ratio_p, commission, - created_at, updated_at - FROM structs.infusion - ORDER BY updated_at DESC NULLS LAST, destination_id, address - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'destination_id, address, destination_type, player_id, fuel, fuel_p, defusing, defusing_p, power, power_p, ratio, ratio_p, commission, created_at, updated_at', + 'structs.infusion', + [], + 'updated_at DESC NULLS LAST, destination_id, address', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } public function infusionByDestination(string $destination_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT destination_id, address, destination_type, player_id, - fuel, fuel_p, defusing, defusing_p, power, power_p, ratio, ratio_p, commission, - created_at, updated_at - FROM structs.infusion - WHERE destination_id = :destination_id - ORDER BY updated_at DESC NULLS LAST, address - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::DESTINATION_ID => $destination_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::DESTINATION_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'destination_id, address, destination_type, player_id, fuel, fuel_p, defusing, defusing_p, power, power_p, ratio, ratio_p, commission, created_at, updated_at', + 'structs.infusion', + [ + 'destination_id = :destination_id', + ], + 'updated_at DESC NULLS LAST, address', + [ApiParameters::DESTINATION_ID => $destination_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::DESTINATION_ID, ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } public function infusionByAddress(string $address, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT destination_id, address, destination_type, player_id, - fuel, fuel_p, defusing, defusing_p, power, power_p, ratio, ratio_p, commission, - created_at, updated_at - FROM structs.infusion - WHERE address = :address - ORDER BY updated_at DESC NULLS LAST, destination_id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::ADDRESS => $address, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::ADDRESS, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'destination_id, address, destination_type, player_id, fuel, fuel_p, defusing, defusing_p, power, power_p, ratio, ratio_p, commission, created_at, updated_at', + 'structs.infusion', + [ + 'address = :address', + ], + 'updated_at DESC NULLS LAST, destination_id', + [ApiParameters::ADDRESS => $address, ApiParameters::PAGE => (string) $page], + [ApiParameters::ADDRESS, ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } public function infusionByPlayerList(string $player_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT destination_id, address, destination_type, player_id, - fuel, fuel_p, defusing, defusing_p, power, power_p, ratio, ratio_p, commission, - created_at, updated_at - FROM structs.infusion - WHERE player_id = :player_id - ORDER BY updated_at DESC NULLS LAST, destination_id, address - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PLAYER_ID => $player_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PLAYER_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'destination_id, address, destination_type, player_id, fuel, fuel_p, defusing, defusing_p, power, power_p, ratio, ratio_p, commission, created_at, updated_at', + 'structs.infusion', + [ + 'player_id = :player_id', + ], + 'updated_at DESC NULLS LAST, destination_id, address', + [ApiParameters::PLAYER_ID => $player_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::PLAYER_ID, ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } // --- ledger (under /api/ledger/list/... to avoid tx_id collision) --- public function ledgerListAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT time, id, address, counterparty, amount, amount_p, block_height, action::text AS action, direction::text AS direction, denom - FROM structs.ledger - ORDER BY time DESC, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'time, id, address, counterparty, amount, amount_p, block_height, action::text AS action, direction::text AS direction, denom', + 'structs.ledger', + [], + 'time DESC, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + null, + ); } public function ledgerListByPlayer(string $player_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT l.time, l.id, l.address, l.counterparty, l.amount, l.amount_p, l.block_height, l.action::text AS action, l.direction::text AS direction, l.denom - FROM structs.ledger l - INNER JOIN structs.player_address pa ON pa.address = l.address AND pa.player_id = :player_id - ORDER BY l.time DESC, l.id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PLAYER_ID => $player_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PLAYER_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'l.time, l.id, l.address, l.counterparty, l.amount, l.amount_p, l.block_height, l.action::text AS action, l.direction::text AS direction, l.denom', + 'structs.ledger l INNER JOIN structs.player_address pa ON pa.address = l.address AND pa.player_id = :player_id', + [], + 'l.time DESC, l.id', + [ApiParameters::PLAYER_ID => $player_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::PLAYER_ID, ApiParameters::PAGE], + $page, + null, + ); } public function ledgerListByAddress(string $address, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT time, id, address, counterparty, amount, amount_p, block_height, action::text AS action, direction::text AS direction, denom - FROM structs.ledger - WHERE address = :address - ORDER BY time DESC, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::ADDRESS => $address, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::ADDRESS, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'time, id, address, counterparty, amount, amount_p, block_height, action::text AS action, direction::text AS direction, denom', + 'structs.ledger', + [ + 'address = :address', + ], + 'time DESC, id', + [ApiParameters::ADDRESS => $address, ApiParameters::PAGE => (string) $page], + [ApiParameters::ADDRESS, ApiParameters::PAGE], + $page, + null, + ); } // --- permission --- public function permissionAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, object_type, object_index, object_id, player_id, val, updated_at - FROM structs.permission - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, object_type, object_index, object_id, player_id, val, updated_at', + 'structs.permission', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function permissionByObject(string $object_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, object_type, object_index, object_id, player_id, val, updated_at - FROM structs.permission - WHERE object_id = :object_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::OBJECT_ID => $object_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::OBJECT_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, object_type, object_index, object_id, player_id, val, updated_at', + 'structs.permission', + [ + 'object_id = :object_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::OBJECT_ID => $object_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::OBJECT_ID, ApiParameters::PAGE], + $page, + ); } public function permissionByPlayer(string $player_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, object_type, object_index, object_id, player_id, val, updated_at - FROM structs.permission - WHERE player_id = :player_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PLAYER_ID => $player_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PLAYER_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, object_type, object_index, object_id, player_id, val, updated_at', + 'structs.permission', + [ + 'player_id = :player_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PLAYER_ID => $player_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::PLAYER_ID, ApiParameters::PAGE], + $page, + ); } // --- permission_guild_rank --- public function permissionGuildRankAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT object_id, guild_id, permission, rank, updated_at - FROM structs.permission_guild_rank - ORDER BY updated_at DESC NULLS LAST, object_id, guild_id, permission - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'object_id, guild_id, permission, rank, updated_at', + 'structs.permission_guild_rank', + [], + 'updated_at DESC NULLS LAST, object_id, guild_id, permission', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } public function permissionGuildRankByObject(string $object_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT object_id, guild_id, permission, rank, updated_at - FROM structs.permission_guild_rank - WHERE object_id = :object_id - ORDER BY updated_at DESC NULLS LAST, guild_id, permission - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::OBJECT_ID => $object_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::OBJECT_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'object_id, guild_id, permission, rank, updated_at', + 'structs.permission_guild_rank', + [ + 'object_id = :object_id', + ], + 'updated_at DESC NULLS LAST, guild_id, permission', + [ApiParameters::OBJECT_ID => $object_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::OBJECT_ID, ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } public function permissionGuildRankByGuild(string $guild_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT object_id, guild_id, permission, rank, updated_at - FROM structs.permission_guild_rank - WHERE guild_id = :guild_id - ORDER BY updated_at DESC NULLS LAST, object_id, permission - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::GUILD_ID => $guild_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::GUILD_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'object_id, guild_id, permission, rank, updated_at', + 'structs.permission_guild_rank', + [ + 'guild_id = :guild_id', + ], + 'updated_at DESC NULLS LAST, object_id, permission', + [ApiParameters::GUILD_ID => $guild_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::GUILD_ID, ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } // --- planet --- public function planetListAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, max_ore, creator, owner, map, space_slots, air_slots, land_slots, water_slots, status, - location_list_start, location_list_end, created_at, updated_at - FROM structs.planet - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, max_ore, creator, owner, map, space_slots, air_slots, land_slots, water_slots, status, location_list_start, location_list_end, created_at, updated_at', + 'structs.planet', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function planetListByOwner(string $owner, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, max_ore, creator, owner, map, space_slots, air_slots, land_slots, water_slots, status, - location_list_start, location_list_end, created_at, updated_at - FROM structs.planet - WHERE owner = :owner - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::OWNER => $owner, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::OWNER, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, max_ore, creator, owner, map, space_slots, air_slots, land_slots, water_slots, status, location_list_start, location_list_end, created_at, updated_at', + 'structs.planet', + [ + 'owner = :owner', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::OWNER => $owner, ApiParameters::PAGE => (string) $page], + [ApiParameters::OWNER, ApiParameters::PAGE], + $page, + ); } // --- planet_activity (hypertable; ordered by time DESC) --- public function planetActivityAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT time, seq, planet_id, category::text AS category, detail - FROM structs.planet_activity - ORDER BY time DESC, seq DESC - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->planetActivityPage($page, null, null); } public function planetActivityByPlanet(string $planet_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT time, seq, planet_id, category::text AS category, detail - FROM structs.planet_activity - WHERE planet_id = :planet_id - ORDER BY time DESC, seq DESC - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PLANET_ID => $planet_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PLANET_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->planetActivityPage($page, $planet_id, null); } public function planetActivityByCategory(string $category, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT time, seq, planet_id, category::text AS category, detail + return $this->planetActivityPage($page, null, $category); + } + + /** + * @throws Exception + */ + public function planetActivityStats(?string $category, ?string $bucket): Response + { + $responseContent = new ApiResponseContentDto(); + $params = [ApiParameters::CATEGORY => $category, ApiParameters::BUCKET => $bucket]; + $required = []; + $optional = [ApiParameters::CATEGORY, ApiParameters::BUCKET]; + $parsedRequest = $this->apiRequestParsingManager->parse($params, $required, $optional); + $responseContent->errors = $parsedRequest->errors; + if (count($responseContent->errors) > 0) { + return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + } + + $trunc = ($bucket === '1h') ? 'hour' : 'day'; + $categorySql = ''; + $queryParams = []; + if ($category !== null && $category !== '') { + $categorySql = 'AND category = CAST(:category AS structs.grass_category)'; + $queryParams['category'] = $category; + } + + $sql = "SELECT date_trunc('{$trunc}', time) AS bucket, + category::text AS category, + count(*) AS count FROM structs.planet_activity - WHERE category = CAST(:category AS structs.grass_category) - ORDER BY time DESC, seq DESC - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::CATEGORY => $category, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::CATEGORY, ApiParameters::PAGE]; + WHERE time >= NOW() - INTERVAL '30 days' + {$categorySql} + GROUP BY bucket, category + ORDER BY bucket, category"; - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); - } + $db = $this->entityManager->getConnection(); + $responseContent->data = $db->fetchAllAssociative($sql, $queryParams); + $responseContent->success = true; + ResponseMetaUtil::stampHeight($responseContent, $this->entityManager); - // --- player --- + return new JsonResponse($responseContent, Response::HTTP_OK); + } - public function playerListAll(int $page): Response + private function planetActivityPage(int $page, ?string $planet_id, ?string $category): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, creator, primary_address, guild_id, guild_rank, substation_id, planet_id, fleet_id, created_at, updated_at - FROM structs.player - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; + $filters = []; $params = [ApiParameters::PAGE => (string) $page]; $required = [ApiParameters::PAGE]; + if ($planet_id !== null) { + $filters[] = 'planet_id = :planet_id'; + $params[ApiParameters::PLANET_ID] = $planet_id; + $required[] = ApiParameters::PLANET_ID; + } + if ($category !== null) { + $filters[] = 'category = CAST(:category AS structs.grass_category)'; + $params[ApiParameters::CATEGORY] = $category; + $required[] = ApiParameters::CATEGORY; + } + + return $this->listQuery( + 'time, seq, planet_id, category::text AS category, detail', + 'structs.planet_activity', + $filters, + 'time DESC, seq DESC', + $params, + $required, + $page, + $planet_id === null ? null : self::KEYSET_SEQ, + static function (array $row): array { + $decoded = json_decode((string) ($row['detail'] ?? ''), true); + $row['detail_json'] = is_array($decoded) ? $decoded : null; + + return $row; + }, + ); + } + + // --- player --- - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + public function playerListAll(int $page): Response + { + return $this->listQuery( + 'id, index, creator, primary_address, guild_id, guild_rank, substation_id, planet_id, fleet_id, created_at, updated_at', + 'structs.player', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function playerListByGuild(string $guild_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, creator, primary_address, guild_id, guild_rank, substation_id, planet_id, fleet_id, created_at, updated_at - FROM structs.player - WHERE guild_id = :guild_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::GUILD_ID => $guild_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::GUILD_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, index, creator, primary_address, guild_id, guild_rank, substation_id, planet_id, fleet_id, created_at, updated_at', + 'structs.player', + [ + 'guild_id = :guild_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::GUILD_ID => $guild_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::GUILD_ID, ApiParameters::PAGE], + $page, + ); } public function playerListBySubstation(string $substation_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, creator, primary_address, guild_id, guild_rank, substation_id, planet_id, fleet_id, created_at, updated_at - FROM structs.player - WHERE substation_id = :substation_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::SUBSTATION_ID => $substation_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::SUBSTATION_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, index, creator, primary_address, guild_id, guild_rank, substation_id, planet_id, fleet_id, created_at, updated_at', + 'structs.player', + [ + 'substation_id = :substation_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::SUBSTATION_ID => $substation_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::SUBSTATION_ID, ApiParameters::PAGE], + $page, + ); } // --- provider --- public function providerAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, substation_id, rate_amount, rate_denom, access_policy, capacity_minimum, capacity_maximum, - duration_minimum, duration_maximum, provider_cancellation_penalty, consumer_cancellation_penalty, - creator, owner, created_at, updated_at - FROM structs.provider - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, index, substation_id, rate_amount, rate_denom, access_policy, capacity_minimum, capacity_maximum, duration_minimum, duration_maximum, provider_cancellation_penalty, consumer_cancellation_penalty, creator, owner, created_at, updated_at', + 'structs.provider', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function providerByOwner(string $owner, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, substation_id, rate_amount, rate_denom, access_policy, capacity_minimum, capacity_maximum, - duration_minimum, duration_maximum, provider_cancellation_penalty, consumer_cancellation_penalty, - creator, owner, created_at, updated_at - FROM structs.provider - WHERE owner = :owner - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::OWNER => $owner, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::OWNER, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, index, substation_id, rate_amount, rate_denom, access_policy, capacity_minimum, capacity_maximum, duration_minimum, duration_maximum, provider_cancellation_penalty, consumer_cancellation_penalty, creator, owner, created_at, updated_at', + 'structs.provider', + [ + 'owner = :owner', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::OWNER => $owner, ApiParameters::PAGE => (string) $page], + [ApiParameters::OWNER, ApiParameters::PAGE], + $page, + ); } public function providerByDenom(string $denom, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, substation_id, rate_amount, rate_denom, access_policy, capacity_minimum, capacity_maximum, - duration_minimum, duration_maximum, provider_cancellation_penalty, consumer_cancellation_penalty, - creator, owner, created_at, updated_at - FROM structs.provider - WHERE rate_denom = :denom - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::DENOM => $denom, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::DENOM, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, index, substation_id, rate_amount, rate_denom, access_policy, capacity_minimum, capacity_maximum, duration_minimum, duration_maximum, provider_cancellation_penalty, consumer_cancellation_penalty, creator, owner, created_at, updated_at', + 'structs.provider', + [ + 'rate_denom = :denom', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::DENOM => $denom, ApiParameters::PAGE => (string) $page], + [ApiParameters::DENOM, ApiParameters::PAGE], + $page, + ); } public function providerBySubstation(string $substation_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, substation_id, rate_amount, rate_denom, access_policy, capacity_minimum, capacity_maximum, - duration_minimum, duration_maximum, provider_cancellation_penalty, consumer_cancellation_penalty, - creator, owner, created_at, updated_at - FROM structs.provider - WHERE substation_id = :substation_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::SUBSTATION_ID => $substation_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::SUBSTATION_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, index, substation_id, rate_amount, rate_denom, access_policy, capacity_minimum, capacity_maximum, duration_minimum, duration_maximum, provider_cancellation_penalty, consumer_cancellation_penalty, creator, owner, created_at, updated_at', + 'structs.provider', + [ + 'substation_id = :substation_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::SUBSTATION_ID => $substation_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::SUBSTATION_ID, ApiParameters::PAGE], + $page, + ); } // --- reactor --- public function reactorAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, validator, guild_id, default_commission, owner, created_at, updated_at - FROM structs.reactor - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, validator, guild_id, default_commission, owner, created_at, updated_at', + 'structs.reactor', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function reactorByValidator(string $validator_address, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, validator, guild_id, default_commission, owner, created_at, updated_at - FROM structs.reactor - WHERE validator = :validator_address - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::VALIDATOR_ADDRESS => $validator_address, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::VALIDATOR_ADDRESS, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, validator, guild_id, default_commission, owner, created_at, updated_at', + 'structs.reactor', + [ + 'validator = :validator_address', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::VALIDATOR_ADDRESS => $validator_address, ApiParameters::PAGE => (string) $page], + [ApiParameters::VALIDATOR_ADDRESS, ApiParameters::PAGE], + $page, + ); } public function reactorByGuild(string $guild_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, validator, guild_id, default_commission, owner, created_at, updated_at - FROM structs.reactor - WHERE guild_id = :guild_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::GUILD_ID => $guild_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::GUILD_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, validator, guild_id, default_commission, owner, created_at, updated_at', + 'structs.reactor', + [ + 'guild_id = :guild_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::GUILD_ID => $guild_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::GUILD_ID, ApiParameters::PAGE], + $page, + ); } public function reactorByOwner(string $owner, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, validator, guild_id, default_commission, owner, created_at, updated_at - FROM structs.reactor - WHERE owner = :owner - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::OWNER => $owner, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::OWNER, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, validator, guild_id, default_commission, owner, created_at, updated_at', + 'structs.reactor', + [ + 'owner = :owner', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::OWNER => $owner, ApiParameters::PAGE => (string) $page], + [ApiParameters::OWNER, ApiParameters::PAGE], + $page, + ); } // --- substation --- public function substationAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, owner, creator, name, pfp, created_at, updated_at - FROM structs.substation - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, owner, creator, name, pfp, created_at, updated_at', + 'structs.substation', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function substationByOwner(string $owner, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, owner, creator, name, pfp, created_at, updated_at - FROM structs.substation - WHERE owner = :owner - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::OWNER => $owner, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::OWNER, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, owner, creator, name, pfp, created_at, updated_at', + 'structs.substation', + [ + 'owner = :owner', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::OWNER => $owner, ApiParameters::PAGE => (string) $page], + [ApiParameters::OWNER, ApiParameters::PAGE], + $page, + ); } // --- struct --- public function structListAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, type, creator, owner, location_type, location_id, operating_ambit, slot, - is_destroyed, destroyed_block, created_at, updated_at - FROM structs.struct - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->structList($page, null, null); } public function structListByOwner(string $owner, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, type, creator, owner, location_type, location_id, operating_ambit, slot, - is_destroyed, destroyed_block, created_at, updated_at - FROM structs.struct - WHERE owner = :owner - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::OWNER => $owner, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::OWNER, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->structList($page, $owner, null); } public function structListByLocation(string $location_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, index, type, creator, owner, location_type, location_id, operating_ambit, slot, - is_destroyed, destroyed_block, created_at, updated_at - FROM structs.struct - WHERE location_id = :location_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::LOCATION_ID => $location_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::LOCATION_ID, ApiParameters::PAGE]; + return $this->structList($page, null, $location_id); + } - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + private function structList(int $page, ?string $owner, ?string $location_id): Response + { + $filters = []; + $params = [ApiParameters::PAGE => (string) $page]; + $required = [ApiParameters::PAGE]; + if ($owner !== null) { + $filters[] = 'owner = :owner'; + $params[ApiParameters::OWNER] = $owner; + $required[] = ApiParameters::OWNER; + } + if ($location_id !== null) { + $filters[] = 'location_id = :location_id'; + $params[ApiParameters::LOCATION_ID] = $location_id; + $required[] = ApiParameters::LOCATION_ID; + } + if ($this->isDestroyed !== null) { + $this->isDestroyedApplied = true; + $filters[] = "is_destroyed = (:is_destroyed = '1')"; + $params[ApiParameters::IS_DESTROYED] = $this->isDestroyed; + $required[] = ApiParameters::IS_DESTROYED; + } + + return $this->listQuery( + 'id, index, type, creator, owner, location_type, location_id, operating_ambit, slot, is_destroyed, destroyed_block, created_at, updated_at', + 'structs.struct', + $filters, + 'updated_at DESC NULLS LAST, id', + $params, + $required, + $page, + ); } // --- struct_attribute --- public function structAttributeAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, object_id, object_type, sub_index, attribute_type, val, updated_at - FROM structs.struct_attribute - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, object_id, object_type, sub_index, attribute_type, val, updated_at', + 'structs.struct_attribute', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function structAttributeByObject(string $object_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, object_id, object_type, sub_index, attribute_type, val, updated_at - FROM structs.struct_attribute - WHERE object_id = :object_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::OBJECT_ID => $object_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::OBJECT_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, object_id, object_type, sub_index, attribute_type, val, updated_at', + 'structs.struct_attribute', + [ + 'object_id = :object_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::OBJECT_ID => $object_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::OBJECT_ID, ApiParameters::PAGE], + $page, + ); } public function structAttributeByType(string $attribute_type, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, object_id, object_type, sub_index, attribute_type, val, updated_at - FROM structs.struct_attribute - WHERE attribute_type = :attribute_type - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::ATTRIBUTE_TYPE => $attribute_type, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::ATTRIBUTE_TYPE, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, object_id, object_type, sub_index, attribute_type, val, updated_at', + 'structs.struct_attribute', + [ + 'attribute_type = :attribute_type', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::ATTRIBUTE_TYPE => $attribute_type, ApiParameters::PAGE => (string) $page], + [ApiParameters::ATTRIBUTE_TYPE, ApiParameters::PAGE], + $page, + ); } // --- struct_defender --- public function structDefenderAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT defending_struct_id, protected_struct_id, updated_at - FROM structs.struct_defender - ORDER BY updated_at DESC NULLS LAST, defending_struct_id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'defending_struct_id, protected_struct_id, updated_at', + 'structs.struct_defender', + [], + 'updated_at DESC NULLS LAST, defending_struct_id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } public function structDefenderByDefending(string $defending_struct_id): Response @@ -1035,58 +1315,174 @@ public function structDefenderByDefending(string $defending_struct_id): Response public function structDefenderByProtected(string $protected_struct_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT defending_struct_id, protected_struct_id, updated_at - FROM structs.struct_defender - WHERE protected_struct_id = :protected_struct_id - ORDER BY updated_at DESC NULLS LAST, defending_struct_id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PROTECTED_STRUCT_ID => $protected_struct_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PROTECTED_STRUCT_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'defending_struct_id, protected_struct_id, updated_at', + 'structs.struct_defender', + [ + 'protected_struct_id = :protected_struct_id', + ], + 'updated_at DESC NULLS LAST, defending_struct_id', + [ApiParameters::PROTECTED_STRUCT_ID => $protected_struct_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::PROTECTED_STRUCT_ID, ApiParameters::PAGE], + $page, + self::FILTER_UPDATED_AT, + ); } // --- planet_attribute --- public function planetAttributeAll(int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, object_id, object_type, attribute_type, val, updated_at - FROM structs.planet_attribute - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, object_id, object_type, attribute_type, val, updated_at', + 'structs.planet_attribute', + [], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::PAGE => (string) $page], + [ApiParameters::PAGE], + $page, + ); } public function planetAttributeByObject(string $object_id, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, object_id, object_type, attribute_type, val, updated_at - FROM structs.planet_attribute - WHERE object_id = :object_id - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::OBJECT_ID => $object_id, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::OBJECT_ID, ApiParameters::PAGE]; - - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + return $this->listQuery( + 'id, object_id, object_type, attribute_type, val, updated_at', + 'structs.planet_attribute', + [ + 'object_id = :object_id', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::OBJECT_ID => $object_id, ApiParameters::PAGE => (string) $page], + [ApiParameters::OBJECT_ID, ApiParameters::PAGE], + $page, + ); } public function planetAttributeByType(string $attribute_type, int $page): Response { - [$limit, $offset] = $this->limitOffset($page); - $sql = "SELECT id, object_id, object_type, attribute_type, val, updated_at - FROM structs.planet_attribute - WHERE attribute_type = :attribute_type - ORDER BY updated_at DESC NULLS LAST, id - LIMIT $limit OFFSET $offset"; - $params = [ApiParameters::ATTRIBUTE_TYPE => $attribute_type, ApiParameters::PAGE => (string) $page]; - $required = [ApiParameters::ATTRIBUTE_TYPE, ApiParameters::PAGE]; + return $this->listQuery( + 'id, object_id, object_type, attribute_type, val, updated_at', + 'structs.planet_attribute', + [ + 'attribute_type = :attribute_type', + ], + 'updated_at DESC NULLS LAST, id', + [ApiParameters::ATTRIBUTE_TYPE => $attribute_type, ApiParameters::PAGE => (string) $page], + [ApiParameters::ATTRIBUTE_TYPE, ApiParameters::PAGE], + $page, + ); + } + + /** + * @throws Exception + */ + public function countFleets(): Response + { + return $this->queryOne($this->entityManager, $this->apiRequestParsingManager, 'SELECT count(*) AS count FROM structs.fleet', [], []); + } + + /** + * @throws Exception + */ + public function providerMarket(): Response + { + $sql = "SELECT + p.id, + p.owner, + p.substation_id, + p.rate_amount, + p.rate_denom, + p.access_policy, + p.capacity_minimum, + p.capacity_maximum, + p.duration_minimum, + p.duration_maximum, + p.provider_cancellation_penalty, + p.consumer_cancellation_penalty, + s.owner AS substation_owner, + gm.id AS guild_id, + CASE + WHEN p.rate_denom IN ('ualpha', 'alpha') THEN p.rate_amount + ELSE floor(p.rate_amount * COALESCE(gb.ratio, 0)) + END AS alpha_equivalent_rate_p, + COALESCE(c.committed_capacity, 0) AS committed_capacity + FROM structs.provider p + LEFT JOIN structs.substation s ON s.id = p.substation_id + LEFT JOIN structs.player pl ON pl.id = p.owner + LEFT JOIN structs.guild_meta gm ON gm.id = pl.guild_id + LEFT JOIN structs.api_guild_bank gb + ON gb.guild_id = pl.guild_id + AND gb.denom = p.rate_denom + LEFT JOIN ( + SELECT provider_id, SUM(capacity) AS committed_capacity + FROM structs.agreement + WHERE end_block IS NULL OR end_block > (SELECT height FROM current_block LIMIT 1) + GROUP BY provider_id + ) c ON c.provider_id = p.id + ORDER BY p.id"; + + return $this->queryAllStamped( + $this->entityManager, + $this->apiRequestParsingManager, + $sql, + [], + [], + 'guild_bank' + ); + } + + /** + * @throws Exception + */ + public function agreementByOwnerMarket(string $owner): Response + { + $sql = "SELECT + a.id, + a.provider_id, + a.allocation_id, + a.capacity, + a.start_block, + a.end_block, + a.creator, + a.owner, + a.created_at, + a.updated_at, + GREATEST(a.end_block - (SELECT height FROM current_block LIMIT 1), 0) AS blocks_remaining, + (a.capacity * COALESCE(p.rate_amount, 0) * GREATEST(a.end_block - (SELECT height FROM current_block LIMIT 1), 0))::text AS escrow_remaining + FROM structs.agreement a + LEFT JOIN structs.provider p ON p.id = a.provider_id + WHERE a.owner = :owner + ORDER BY a.end_block DESC NULLS LAST, a.id"; + $params = [ApiParameters::OWNER => $owner]; + $required = [ApiParameters::OWNER]; + + return $this->queryAllStamped( + $this->entityManager, + $this->apiRequestParsingManager, + $sql, + $params, + $required + ); + } - return $this->queryAll($this->entityManager, $this->apiRequestParsingManager, $sql, $params, $required); + private function gridOrderSql(): ?string + { + $allow = [ + 'val.desc' => 'val DESC, id', + 'val.asc' => 'val ASC, id', + 'updated_at.desc' => 'updated_at DESC NULLS LAST, id', + 'updated_at.asc' => 'updated_at ASC NULLS LAST, id', + 'id.desc' => 'id DESC', + 'id.asc' => 'id ASC', + 'object_id.desc' => 'object_id DESC', + 'object_id.asc' => 'object_id ASC', + ]; + if ($this->order === null) { + return self::GRID_DEFAULT_ORDER; + } + $this->orderApplied = true; + + return $allow[$this->order] ?? null; } } diff --git a/src/src/Manager/WorkManager.php b/src/src/Manager/WorkManager.php index b8b5e8be4..ba9d5aa5b 100644 --- a/src/src/Manager/WorkManager.php +++ b/src/src/Manager/WorkManager.php @@ -122,4 +122,18 @@ public function getAllWorkPaged(int $page): Response $requiredFields ); } + + /** + * @throws Exception + */ + public function countWork(): Response + { + return $this->queryOne( + $this->entityManager, + $this->apiRequestParsingManager, + 'SELECT count(*) AS count FROM view.work', + [], + [] + ); + } } \ No newline at end of file diff --git a/src/src/Trait/ApiSqlQueryTrait.php b/src/src/Trait/ApiSqlQueryTrait.php index 59c4d6023..2626815e1 100644 --- a/src/src/Trait/ApiSqlQueryTrait.php +++ b/src/src/Trait/ApiSqlQueryTrait.php @@ -5,6 +5,7 @@ use App\Dto\ApiParsedRequestDto; use App\Dto\ApiResponseContentDto; use App\Manager\ApiRequestParsingManager; +use App\Util\ResponseMetaUtil; use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; @@ -41,28 +42,16 @@ public function queryOne( array $apiRequestParams, array $apiRequiredParams ):Response { - $responseContent = new ApiResponseContentDto(); - - $parsedRequest = $apiRequestParsingManager->parse( + [$responseContent, $status] = $this->runQuery( + $entityManager, + $apiRequestParsingManager, + $sqlQuery, $apiRequestParams, - $apiRequiredParams + $apiRequiredParams, + false ); - $responseContent->errors = $parsedRequest->errors; - - if (count($responseContent->errors) > 0) { - return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); - } - - $queryParams = $this->getQueryParams($apiRequiredParams, $parsedRequest); - - $db = $entityManager->getConnection(); - $result = $db->fetchAssociative($sqlQuery, $queryParams); - - $responseContent->data = $result === false ? null : $result; - $responseContent->success = true; - - return new JsonResponse($responseContent, Response::HTTP_OK); + return new JsonResponse($responseContent, $status); } /** @@ -81,6 +70,108 @@ public function queryAll( array $apiRequestParams, array $apiRequiredParams ):Response { + [$responseContent, $status] = $this->runQuery( + $entityManager, + $apiRequestParsingManager, + $sqlQuery, + $apiRequestParams, + $apiRequiredParams, + true + ); + + return new JsonResponse($responseContent, $status); + } + + /** + * As queryOne, with the source height stamped onto meta. Building the DTO once + * avoids the decode/re-encode round trip a caller would otherwise need to reach + * inside the finished response. + * + * @throws Exception + */ + public function queryOneStamped( + EntityManagerInterface $entityManager, + ApiRequestParsingManager $apiRequestParsingManager, + string $sqlQuery, + array $apiRequestParams, + array $apiRequiredParams, + ?string $apiModel = null + ): Response { + return $this->stampedQuery( + $entityManager, + $apiRequestParsingManager, + $sqlQuery, + $apiRequestParams, + $apiRequiredParams, + false, + $apiModel + ); + } + + /** + * As queryAll, with the source height stamped onto meta. + * + * @throws Exception + */ + public function queryAllStamped( + EntityManagerInterface $entityManager, + ApiRequestParsingManager $apiRequestParsingManager, + string $sqlQuery, + array $apiRequestParams, + array $apiRequiredParams, + ?string $apiModel = null + ): Response { + return $this->stampedQuery( + $entityManager, + $apiRequestParsingManager, + $sqlQuery, + $apiRequestParams, + $apiRequiredParams, + true, + $apiModel + ); + } + + /** + * @throws Exception + */ + private function stampedQuery( + EntityManagerInterface $entityManager, + ApiRequestParsingManager $apiRequestParsingManager, + string $sqlQuery, + array $apiRequestParams, + array $apiRequiredParams, + bool $fetchAll, + ?string $apiModel + ): Response { + [$responseContent, $status] = $this->runQuery( + $entityManager, + $apiRequestParsingManager, + $sqlQuery, + $apiRequestParams, + $apiRequiredParams, + $fetchAll + ); + + if ($responseContent->success) { + ResponseMetaUtil::stampHeight($responseContent, $entityManager, $apiModel); + } + + return new JsonResponse($responseContent, $status); + } + + /** + * @return array{0: ApiResponseContentDto, 1: int} + * @throws Exception + */ + private function runQuery( + EntityManagerInterface $entityManager, + ApiRequestParsingManager $apiRequestParsingManager, + string $sqlQuery, + array $apiRequestParams, + array $apiRequiredParams, + bool $fetchAll + ): array { $responseContent = new ApiResponseContentDto(); $parsedRequest = $apiRequestParsingManager->parse( @@ -91,17 +182,22 @@ public function queryAll( $responseContent->errors = $parsedRequest->errors; if (count($responseContent->errors) > 0) { - return new JsonResponse($responseContent, Response::HTTP_BAD_REQUEST); + return [$responseContent, Response::HTTP_BAD_REQUEST]; } $queryParams = $this->getQueryParams($apiRequiredParams, $parsedRequest); $db = $entityManager->getConnection(); - $result = $db->fetchAllAssociative($sqlQuery, $queryParams); - $responseContent->data = $result; + if ($fetchAll) { + $responseContent->data = $db->fetchAllAssociative($sqlQuery, $queryParams); + } else { + $result = $db->fetchAssociative($sqlQuery, $queryParams); + $responseContent->data = $result === false ? null : $result; + } + $responseContent->success = true; - return new JsonResponse($responseContent, Response::HTTP_OK); + return [$responseContent, Response::HTTP_OK]; } -} \ No newline at end of file +} diff --git a/src/src/Trait/ObjectKeyTrait.php b/src/src/Trait/ObjectKeyTrait.php new file mode 100644 index 000000000..26d8ea86c --- /dev/null +++ b/src/src/Trait/ObjectKeyTrait.php @@ -0,0 +1,33 @@ +-", so only the prefix needs matching. + foreach (ObjectTypes::PREFIXES as $prefix => $objectType) { + if (str_starts_with($object_key, $prefix . '-')) { + return [ + 'object_type' => $objectType, + 'object_index' => (int) substr($object_key, strlen($prefix) + 1), + ]; + } + } + + return null; + } +} diff --git a/src/src/Util/ResponseMetaUtil.php b/src/src/Util/ResponseMetaUtil.php new file mode 100644 index 000000000..5f5e4bc3e --- /dev/null +++ b/src/src/Util/ResponseMetaUtil.php @@ -0,0 +1,38 @@ +getConnection(); + + if ($apiModel !== null) { + $row = $db->fetchAssociative( + 'SELECT source_height FROM structs.api_refresh_state WHERE model = :model LIMIT 1', + ['model' => $apiModel] + ); + if (is_array($row) && array_key_exists('source_height', $row)) { + $responseContent->meta = ['height' => $row['source_height']]; + + return; + } + } + + $row = $db->fetchAssociative('SELECT height FROM current_block LIMIT 1'); + if (is_array($row) && array_key_exists('height', $row)) { + $responseContent->meta = ['height' => $row['height']]; + } + } +} diff --git a/src/tests/ApiManagerTestCase.php b/src/tests/ApiManagerTestCase.php new file mode 100644 index 000000000..f2a3a8553 --- /dev/null +++ b/src/tests/ApiManagerTestCase.php @@ -0,0 +1,49 @@ +createStub(EntityManagerInterface::class); + $entityManager->method('getConnection')->willReturn($connection); + + return $entityManager; + } + + protected function validator(): ValidatorInterface + { + return Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + } + + /** + * Returns a connection whose first fetchAllAssociative call records the SQL it + * was given, so a test can assert on the query the manager composed. + */ + protected function capturingConnection(?string &$captured, array $rows = []): Connection + { + $connection = $this->createMock(Connection::class); + $connection->expects($this->once()) + ->method('fetchAllAssociative') + ->willReturnCallback(function (string $sql) use (&$captured, $rows) { + $captured = $sql; + + return $rows; + }); + $connection->method('fetchAssociative')->willReturn(['source_height' => 1]); + + return $connection; + } +} diff --git a/src/tests/ApiResponseContentDtoTest.php b/src/tests/ApiResponseContentDtoTest.php new file mode 100644 index 000000000..91a5ff7f6 --- /dev/null +++ b/src/tests/ApiResponseContentDtoTest.php @@ -0,0 +1,36 @@ +success = true; + $dto->errors = []; + $dto->data = ['count' => 1]; + + $encoded = json_decode(json_encode($dto), true); + $this->assertSame(['success', 'errors', 'data'], array_keys($encoded)); + $this->assertArrayNotHasKey('total', $encoded); + $this->assertArrayNotHasKey('meta', $encoded); + } + + public function testIncludesTotalAndMetaWhenSet(): void + { + $dto = new ApiResponseContentDto(); + $dto->success = true; + $dto->total = 42; + $dto->meta = ['height' => 7]; + + $encoded = json_decode(json_encode($dto), true); + $this->assertSame(42, $encoded['total']); + $this->assertSame(['height' => 7], $encoded['meta']); + } +} diff --git a/src/tests/ApiRoutingTest.php b/src/tests/ApiRoutingTest.php new file mode 100644 index 000000000..9c721a44e --- /dev/null +++ b/src/tests/ApiRoutingTest.php @@ -0,0 +1,49 @@ + 'test']); + $router = static::getContainer()->get('router'); + $router->getContext()->setMethod('GET'); + + return $router; + } + + /** /api/ledger/stats must win over /api/ledger/transaction/{tx_id}-style patterns. */ + public function testLedgerStatsRouteIsNotCapturedAsTxId(): void + { + $match = $this->router()->match('/api/ledger/stats'); + + $this->assertSame('api_ledger_stats', $match['_route']); + $this->assertArrayNotHasKey('tx_id', $match); + } + + /** + * Literal segments declared alongside {id} patterns only win because they are + * declared first, so this pins the resolution rather than the declaration order. + * + * @dataProvider literalRouteProvider + */ + public function testLiteralRoutesResolve(string $path, string $expected): void + { + $this->assertSame($expected, $this->router()->match($path)['_route']); + } + + public static function literalRouteProvider(): array + { + return [ + ['/api/leaderboard/player', 'api_leaderboard'], + ['/api/player/count', 'api_count_players'], + ['/api/player/1-101', 'api_get_player'], + ['/api/struct/count', 'api_count_structs'], + ['/api/planet/count', 'api_count_planets'], + ['/api/stat/load/aggregate/range', 'api_stat_aggregate_range'], + ['/api/player/power/at-risk', 'api_player_power_at_risk'], + ['/api/player/1-101/power', 'api_get_player_power'], + ]; + } +} diff --git a/src/tests/GuildManagerTest.php b/src/tests/GuildManagerTest.php new file mode 100644 index 000000000..48339d6bf --- /dev/null +++ b/src/tests/GuildManagerTest.php @@ -0,0 +1,98 @@ +captureRosterSql(); + + $this->assertStringContainsString('view.player_inventory', $sql); + $this->assertStringContainsString('as alpha,', $sql); + $this->assertStringContainsString('structs.api_inventory', $sql); + $this->assertStringContainsString('as alpha_p', $sql); + } + + /** + * Roster alpha is a display figure, not spendable liquidity, so infused and + * defusing holdings count toward it on both scales. + */ + public function testRosterCombinesAlphaStates(): void + { + $sql = $this->captureRosterSql(); + + $this->assertStringContainsString("denom IN ('alpha', 'alpha.infused', 'alpha.defusing')", $sql); + $this->assertStringContainsString("denom IN ('ualpha', 'ualpha.infused', 'ualpha.defusing')", $sql); + } + + /** + * view.player owns the milliwatt-to-watt arithmetic. Re-deriving it here from + * grid once produced numbers that diverged from every other consumer, so the + * scaling must stay in the view rather than being reimplemented in a Manager. + */ + public function testGuildPowerStatsSourcesFromViewPlayer(): void + { + $captured = null; + $connection = $this->createMock(Connection::class); + $connection->expects($this->once()) + ->method('fetchAssociative') + ->willReturnCallback(function (string $sql) use (&$captured) { + $captured = $sql; + + return ['guild_id' => '0-1', 'total_load' => 1, 'total_capacity' => 1, 'avg_connection_capacity' => 1, 'total_fuel' => 0]; + }); + + $manager = new GuildManager($this->entityManager($connection), $this->validator()); + $response = $manager->getGuildPowerStats('0-1'); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + $this->assertStringContainsString('view.player', $captured); + $this->assertStringNotContainsString('attribute_type', $captured); + $this->assertStringNotContainsString('/ 1000', $captured); + } + + /** Display and precision columns are served side by side. */ + public function testGuildPowerStatsExposesBothScales(): void + { + $captured = null; + $connection = $this->createMock(Connection::class); + $connection->method('fetchAssociative') + ->willReturnCallback(function (string $sql) use (&$captured) { + $captured = $sql; + + return ['guild_id' => '0-1']; + }); + + (new GuildManager($this->entityManager($connection), $this->validator())) + ->getGuildPowerStats('0-1'); + + foreach (['total_load', 'total_capacity', 'total_fuel', 'avg_connection_capacity'] as $field) { + $this->assertStringContainsString("as {$field},", $captured . ','); + $this->assertStringContainsString("as {$field}_p", $captured); + } + } + + private function captureRosterSql(): string + { + $captured = null; + $manager = new GuildManager( + $this->entityManager($this->capturingConnection($captured)), + $this->validator() + ); + $manager->getGuildRoster('0-1'); + + $this->assertNotNull($captured); + + return $captured; + } +} diff --git a/src/tests/InventoryManagerTest.php b/src/tests/InventoryManagerTest.php new file mode 100644 index 000000000..25ffce5fb --- /dev/null +++ b/src/tests/InventoryManagerTest.php @@ -0,0 +1,32 @@ +capturingConnection($captured); + + (new InventoryManager($this->entityManager($connection), $this->validator())) + ->inventoryByOwner('player', '1-1'); + + $this->assertStringContainsString('balance::text AS balance', $captured); + } + + /** Guild bank volume aggregates the precise column, matching its ualpha denom label. */ + public function testGuildBankHistoryAggregatesPreciseAmounts(): void + { + $captured = null; + $connection = $this->capturingConnection($captured); + + (new InventoryManager($this->entityManager($connection), $this->validator())) + ->getGuildBankHistory('0-1', '1d'); + + $this->assertStringContainsString('l.amount_p', $captured); + $this->assertStringNotContainsString('l.amount ', $captured); + } +} diff --git a/src/tests/LeaderboardManagerTest.php b/src/tests/LeaderboardManagerTest.php new file mode 100644 index 000000000..3b7aacc29 --- /dev/null +++ b/src/tests/LeaderboardManagerTest.php @@ -0,0 +1,41 @@ +createMock(Connection::class); + $connection->expects($this->never())->method('fetchAllAssociative'); + + $manager = new LeaderboardManager($this->entityManager($connection), $this->validator()); + $response = $manager->getLeaderboard('player', 'drop_table.desc', '10'); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $content = json_decode($response->getContent(), true); + $this->assertArrayHasKey('order_invalid', $content['errors']); + } + + /** + * The api_leaderboard_* tables hold base denominations under bare column names, + * so the API renames them on the way out rather than shipping an ambiguous + * `alpha_balance` that is actually ualpha. + */ + public function testAmountsAreExposedAsPrecisionColumns(): void + { + $captured = null; + $connection = $this->capturingConnection($captured); + + (new LeaderboardManager($this->entityManager($connection), $this->validator())) + ->getLeaderboard('player', null, '10'); + + $this->assertStringContainsString('AS alpha_balance_p', $captured); + $this->assertStringContainsString('AS alpha_value_p', $captured); + $this->assertStringNotContainsString('AS alpha_balance,', $captured); + $this->assertStringContainsString('::text', $captured); + } +} diff --git a/src/tests/ObjectTypesTest.php b/src/tests/ObjectTypesTest.php new file mode 100644 index 000000000..00639bdb4 --- /dev/null +++ b/src/tests/ObjectTypesTest.php @@ -0,0 +1,35 @@ +assertSame($fromPrefixes, $all); + } + + public function testPrefixesAreLongestFirst(): void + { + $keys = array_keys(ObjectTypes::PREFIXES); + $multiCharPositions = []; + foreach ($keys as $position => $key) { + if (strlen($key) > 1) { + $multiCharPositions[] = $position; + } + } + + // 10 and 11 must be tested before 1, or 10-1 parses as a player. + $this->assertSame([0, 1], $multiCharPositions); + } +} diff --git a/src/tests/PaginationLimitsTest.php b/src/tests/PaginationLimitsTest.php new file mode 100644 index 000000000..131cd7c8a --- /dev/null +++ b/src/tests/PaginationLimitsTest.php @@ -0,0 +1,25 @@ +assertSame(100, PaginationLimits::clamp(null)); + $this->assertSame(100, PaginationLimits::clamp(0)); + $this->assertSame(1, PaginationLimits::clamp(1)); + $this->assertSame(1000, PaginationLimits::clamp(5000)); + $this->assertSame(50, PaginationLimits::clamp(null, PaginationLimits::LEADERBOARD_DEFAULT)); + } + + /** Callers hand it raw query-string values, so it has to absorb those directly. */ + public function testClampAcceptsRawQueryStringValues(): void + { + $this->assertSame(100, PaginationLimits::clamp('')); + $this->assertSame(25, PaginationLimits::clamp('25')); + $this->assertSame(1000, PaginationLimits::clamp('99999')); + $this->assertSame(100, PaginationLimits::clamp('-5')); + } +} diff --git a/src/tests/ResolveManagerTest.php b/src/tests/ResolveManagerTest.php new file mode 100644 index 000000000..cb03bfe65 --- /dev/null +++ b/src/tests/ResolveManagerTest.php @@ -0,0 +1,82 @@ +createMock(Connection::class); + $connection->expects($this->once())->method('fetchAllAssociative')->willReturn([ + // guild_meta.denom is jsonb and comes off the wire as a string. + ['id' => '0-1', 'symbols' => '{"0": "eep", "6": "bleep"}'], + ['id' => '0-2', 'symbols' => null], + ]); + $connection->method('fetchAssociative')->willReturn(['height' => 1]); + + $response = (new ResolveManager($this->entityManager($connection), $this->validator()))->getDenoms(); + $content = json_decode($response->getContent(), true); + $units = array_column($content['data']['units'], null, 'denom'); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + + $this->assertSame(6, $units['ualpha']['exponent']); + $this->assertSame('alpha', $units['ualpha']['display']); + $this->assertSame('mass', $units['ualpha']['quantity']); + $this->assertSame(0, $units['ore']['exponent']); + + // alpha is never a denomination of its own, only the display of ualpha. + $this->assertArrayNotHasKey('alpha', $units); + + // Guild denoms carry the same 1e6 exponent and lose the leading u for display, + // with the guild's branded symbols exposed as the scale when it registered any. + $this->assertSame(6, $units['uguild.0-1']['exponent']); + $this->assertSame('guild.0-1', $units['uguild.0-1']['display']); + $this->assertSame( + [['exponent' => 0, 'symbol' => 'eep'], ['exponent' => 6, 'symbol' => 'bleep']], + $units['uguild.0-1']['scale'] + ); + $this->assertSame('guild.0-2', $units['uguild.0-2']['display']); + $this->assertArrayNotHasKey('scale', $units['uguild.0-2']); + + $this->assertSame(['infused', 'defusing'], $content['data']['state_suffixes']); + $this->assertSame(0, $content['data']['unknown_denom']['exponent']); + $this->assertSame('_p', $content['data']['precision_suffix']); + + $this->assertSame('milliwatt', $content['data']['bases']['energy']['denom']); + $this->assertSame(3, $content['data']['bases']['energy']['exponent']); + $this->assertTrue($content['data']['bases']['energy']['connection_capacity_is_player_share']); + } + + public function testResolveNameSearchIncludesSubstation(): void + { + $captured = null; + $connection = $this->capturingConnection($captured); + + (new ResolveManager($this->entityManager($connection), $this->validator()))->resolve('orbital'); + + $this->assertStringContainsString('structs.substation', $captured); + $this->assertStringContainsString('structs.player', $captured); + $this->assertStringContainsString('structs.guild', $captured); + } + + /** 10-1 must resolve as a provider, not as player 0-1. */ + public function testObjectKeyPrefixesAreLongestFirst(): void + { + $connection = $this->createMock(Connection::class); + $connection->method('fetchAssociative')->willReturn(['id' => '10-1']); + + $response = (new ResolveManager($this->entityManager($connection), $this->validator()))->resolve('10-1'); + $content = json_decode($response->getContent(), true); + + $this->assertSame('provider', $content['data'][0]['type']); + } +} diff --git a/src/tests/StatReadManagerTest.php b/src/tests/StatReadManagerTest.php new file mode 100644 index 000000000..33a31a153 --- /dev/null +++ b/src/tests/StatReadManagerTest.php @@ -0,0 +1,120 @@ +createMock(Connection::class); + $connection->expects($this->never())->method('fetchAllAssociative'); + + $manager = new StatReadManager($this->entityManager($connection), $this->validator()); + $response = $manager->getStatRange( + 'structs_load', + '5-1', + 1, + (string) time(), + (string) (time() + 60) + ); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $content = json_decode($response->getContent(), true); + $this->assertArrayHasKey('object_key_invalid', $content['errors']); + } + + /** + * Family-two tables have no object_type column, so a cross-type request has to + * 4xx rather than silently scanning the whole table. + */ + public function testFamilyTwoAggregateRejectsWrongObjectType(): void + { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('fetchAllAssociative'); + + $manager = new StatReadManager($this->entityManager($connection), $this->validator()); + $response = $manager->getStatAggregate( + 'connection_capacity', + 'player', + (string) time(), + (string) (time() + 3600), + '1h' + ); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $content = json_decode($response->getContent(), true); + $this->assertArrayHasKey('object_type_invalid', $content['errors']); + } + + /** An unknown metric is refused before any table name is interpolated. */ + public function testAggregateRejectsUnknownMetric(): void + { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('fetchAllAssociative'); + + $response = (new StatReadManager($this->entityManager($connection), $this->validator())) + ->getStatAggregate('not_a_metric', 'player', (string) time(), (string) (time() + 3600), '1h'); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertNotEmpty(json_decode($response->getContent(), true)['errors']); + } + + /** + * ApiRequestParamsDto::$metric carries its own Assert\Choice list, so a metric + * added to the manager's table maps is silently rejected at validation until + * the DTO is updated too. + */ + public function testDtoMetricChoicesMatchTheManagerTables(): void + { + $managerMetrics = []; + foreach (['FAMILY_ONE_TABLES', 'FAMILY_TWO_TABLES'] as $const) { + $managerMetrics = array_merge( + $managerMetrics, + array_keys((new ReflectionClass(StatReadManager::class))->getConstant($const)) + ); + } + + $property = new ReflectionProperty(ApiRequestParamsDto::class, 'metric'); + $dtoMetrics = []; + foreach ($property->getAttributes(Choice::class) as $attribute) { + $arguments = $attribute->getArguments(); + $dtoMetrics = array_merge($dtoMetrics, $arguments['choices'] ?? $arguments[0]); + } + + sort($managerMetrics); + sort($dtoMetrics); + $this->assertSame($managerMetrics, $dtoMetrics); + } + + /** + * Samples are change-triggered, so a bucket has to carry forward each object's + * last known value rather than aggregating only the objects that moved. The + * seed CTE bounds the history scan; without it the query scanned the whole + * table once per bucket and timed out at the maximum window. + */ + public function testAggregateUsesBoundedCarryForward(): void + { + $captured = null; + $connection = $this->capturingConnection($captured); + + (new StatReadManager($this->entityManager($connection), $this->validator())) + ->getStatAggregate('ore', 'player', (string) (time() - 3600), (string) time(), '1h'); + + $this->assertStringContainsString('seed AS', $captured); + $this->assertStringContainsString('in_range AS', $captured); + $this->assertStringContainsString('AS population', $captured); + $this->assertStringContainsString('AS samples', $captured); + + // Empty buckets report null, not a false zero that would draw an opening ramp. + $this->assertStringContainsString('CASE WHEN population > 0 THEN total END AS sum', $captured); + + // Both ends of the window are bounded. + $this->assertStringContainsString('s.time >= (SELECT b0 + step FROM bounds)', $captured); + $this->assertStringContainsString('s.time < (SELECT bn + step FROM bounds)', $captured); + } +} diff --git a/src/tests/TableReadManagerTest.php b/src/tests/TableReadManagerTest.php new file mode 100644 index 000000000..b8e68006c --- /dev/null +++ b/src/tests/TableReadManagerTest.php @@ -0,0 +1,180 @@ +createMock(Connection::class); + $connection->expects($this->never())->method('fetchAllAssociative'); + + $response = $this->manager($connection, ['order' => 'injected.desc']) + ->gridByAttributeType('load', 1); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $content = json_decode($response->getContent(), true); + $this->assertArrayHasKey('order_invalid', $content['errors']); + } + + /** A cursor and an offset together would skip rows, so the cursor replaces it. */ + public function testAfterIdDropsOffset(): void + { + $captured = null; + $connection = $this->capturingConnection($captured); + + $this->manager($connection, ['after_id' => '1-1'])->playerListAll(2); + + $this->assertStringContainsString(':after_id', $captured); + $this->assertDoesNotMatchRegularExpression('/OFFSET\s+\d+/i', $captured); + } + + /** + * Pages run updated_at DESC, id ASC, so the tie-breaker has to move forward + * through ids. A plain tuple comparison would walk it backwards and re-serve + * rows that share the cursor's timestamp. + */ + public function testAfterIdFollowsThePageOrder(): void + { + $captured = null; + $connection = $this->capturingConnection($captured); + + $this->manager($connection, ['after_id' => '1-1'])->playerListAll(1); + + $this->assertStringContainsString("COALESCE(updated_at, '-infinity') <", $captured); + $this->assertStringContainsString('AND id > :after_id', $captured); + } + + /** grid.object_type is varchar, so casting the filter to the enum would not compare. */ + public function testGridObjectTypeFilterDoesNotCastToEnum(): void + { + $captured = null; + $connection = $this->capturingConnection($captured); + + $this->manager($connection, [])->gridByAttributeTypeAndObjectType('load', 'player', 1); + + $this->assertStringContainsString('object_type = :object_type', $captured); + $this->assertStringNotContainsString('structs.object_type', $captured); + } + + public function testIncludeMetaStampsHeight(): void + { + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('fetchAllAssociative')->willReturn([]); + $connection->expects($this->once())->method('fetchAssociative')->willReturn(['height' => 42]); + + $response = $this->manager($connection, ['include_meta' => '1'])->playerListAll(1); + $content = json_decode($response->getContent(), true); + + $this->assertSame(42, $content['meta']['height']); + } + + /** The total counts the filtered relation directly, not a wrapped page query. */ + public function testIncludeTotalCountsWithoutTheOrderedPage(): void + { + $captured = null; + $connection = $this->createMock(Connection::class); + $connection->method('fetchAllAssociative')->willReturn([]); + $connection->expects($this->once()) + ->method('fetchOne') + ->willReturnCallback(function (string $sql) use (&$captured) { + $captured = $sql; + + return 7; + }); + + $response = $this->manager($connection, ['include_total' => '1'])->playerListAll(1); + + $this->assertSame(7, json_decode($response->getContent(), true)['total']); + $this->assertStringContainsString('SELECT count(*) FROM structs.player', $captured); + $this->assertStringNotContainsString('ORDER BY', $captured); + $this->assertStringNotContainsString('LIMIT', $captured); + } + + /** + * Cursor support is declared per endpoint. A consumer paging a feed needs to be + * told its cursor was ignored, not handed a silently offset-paged result. + * + * @dataProvider unsupportedParamProvider + */ + public function testUnsupportedListParamsAreRejected(string $param, string $value, string $method): void + { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('fetchAllAssociative'); + + $response = $this->manager($connection, [$param => $value])->{$method}(1); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $content = json_decode($response->getContent(), true); + $this->assertArrayHasKey("{$param}_unsupported", $content['errors']); + } + + public static function unsupportedParamProvider(): array + { + return [ + // ledger is ordered by (time, id) so neither cursor nor updated_since apply + 'after_id on ledger' => ['after_id', '5-1', 'ledgerListAll'], + 'updated_since on ledger' => ['updated_since', '1', 'ledgerListAll'], + // struct has no seq column + 'since_seq on struct' => ['since_seq', '1', 'structListAll'], + // seq counts per planet, so it is only a cursor once filtered to one planet + 'since_seq on planet_activity all' => ['since_seq', '1', 'planetActivityAll'], + 'after_id on planet_activity all' => ['after_id', '5-1', 'planetActivityAll'], + // order and is_destroyed are only understood by grid and struct reads + 'order on player' => ['order', 'id.asc', 'playerListAll'], + 'is_destroyed on player' => ['is_destroyed', '1', 'playerListAll'], + ]; + } + + /** A single planet's activity pages on its seq counter rather than the (updated_at, id) tuple. */ + public function testPlanetActivityByPlanetUsesSeqCursor(): void + { + $captured = null; + $connection = $this->capturingConnection($captured); + + $this->manager($connection, ['since_seq' => '500'])->planetActivityByPlanet('2-1', 1); + + $this->assertStringContainsString('seq > :since_seq', $captured); + $this->assertDoesNotMatchRegularExpression('/OFFSET\s+\d+/i', $captured); + } + + public function testGridHonoursAllowlistedOrder(): void + { + $captured = null; + $connection = $this->capturingConnection($captured); + + $this->manager($connection, ['order' => 'val.desc'])->gridByAttributeType('load', 1); + + $this->assertStringContainsString('ORDER BY val DESC, id', $captured); + } + + public function testStructListHonoursIsDestroyed(): void + { + $captured = null; + $connection = $this->capturingConnection($captured); + + $this->manager($connection, ['is_destroyed' => '0'])->structListAll(1); + + $this->assertStringContainsString("is_destroyed = (:is_destroyed = '1')", $captured); + } + + public function testUpdatedSinceFiltersSupportedEndpoint(): void + { + $captured = null; + $connection = $this->capturingConnection($captured); + + $this->manager($connection, ['updated_since' => '1700000000'])->structListAll(1); + + $this->assertStringContainsString('updated_at > to_timestamp(:updated_since)', $captured); + } + + private function manager(Connection $connection, array $query): TableReadManager + { + return (new TableReadManager($this->entityManager($connection), $this->validator())) + ->applyListQuery(Request::create('/api/test', 'GET', $query)); + } +} From 542d4fbef38414b493c7c10e99ef1f21b25ca729 Mon Sep 17 00:00:00 2001 From: abstrct Date: Wed, 2 Sep 2026 12:05:05 -0400 Subject: [PATCH 2/2] Removing rouge discord_username column. Fixing universal endpoint. --- src/src/Manager/LeaderboardManager.php | 6 +- src/src/Manager/ResolveManager.php | 86 +++++++++++++++++++++----- 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/src/src/Manager/LeaderboardManager.php b/src/src/Manager/LeaderboardManager.php index 16eb2495d..019ae0408 100644 --- a/src/src/Manager/LeaderboardManager.php +++ b/src/src/Manager/LeaderboardManager.php @@ -31,11 +31,7 @@ class LeaderboardManager 'model' => 'leaderboard_player', 'select' => 'player_id, username, guild_id, alpha_balance::text AS alpha_balance_p, - alpha_value::text AS alpha_value_p, - (SELECT pd.discord_username - FROM structs.player_discord pd - WHERE pd.player_id = lb.player_id - LIMIT 1) AS discord_username', + alpha_value::text AS alpha_value_p', 'default_order' => 'alpha_value DESC NULLS LAST, player_id', 'orders' => [ 'alpha_value.desc' => 'alpha_value DESC NULLS LAST, player_id', diff --git a/src/src/Manager/ResolveManager.php b/src/src/Manager/ResolveManager.php index 5a278eb1b..72a7919e7 100644 --- a/src/src/Manager/ResolveManager.php +++ b/src/src/Manager/ResolveManager.php @@ -21,19 +21,74 @@ class ResolveManager use ApiSqlQueryTrait; use ObjectKeyTrait; - /** @var array */ + /** + * Resolvable object types, with the columns each exposes. The projections are + * explicit rather than SELECT * so a column added to one of these tables is not + * published to every authenticated player on the next deploy. + * + * Two object types are deliberately absent and resolve to null: `address` (8-N) + * has no backing table, and `infusion` (7-N) is keyed on + * (destination_id, player_id) rather than a single id, so it is not addressable + * by object key at all. + * + * @var array + */ private const TYPE_TABLES = [ - 'guild' => 'structs.guild', - 'player' => 'structs.player', - 'planet' => 'structs.planet', - 'reactor' => 'structs.reactor', - 'substation' => 'structs.substation', - 'struct' => 'structs.struct', - 'allocation' => 'structs.allocation', - 'infusion' => 'structs.infusion', - 'fleet' => 'structs.fleet', - 'provider' => 'structs.provider', - 'agreement' => 'structs.agreement', + 'guild' => [ + 'table' => 'structs.guild', + 'columns' => 'id, index, endpoint, join_infusion_minimum, join_infusion_minimum_p, + join_infusion_minimum_bypass_by_request, join_infusion_minimum_bypass_by_invite, + primary_reactor_id, entry_substation_id, entry_rank, name, pfp, + creator, owner, created_at, updated_at', + ], + 'player' => [ + 'table' => 'structs.player', + 'columns' => 'id, index, creator, primary_address, guild_id, guild_rank, substation_id, + planet_id, fleet_id, username, pfp, pfp_client_render_attributes, + created_at, updated_at', + ], + 'planet' => [ + 'table' => 'structs.planet', + 'columns' => 'id, max_ore, creator, owner, map, space_slots, air_slots, land_slots, + water_slots, status, location_list_start, location_list_end, name, + created_at, updated_at', + ], + 'reactor' => [ + 'table' => 'structs.reactor', + 'columns' => 'id, validator, guild_id, default_commission, owner, created_at, updated_at', + ], + 'substation' => [ + 'table' => 'structs.substation', + 'columns' => 'id, owner, creator, name, pfp, created_at, updated_at', + ], + 'struct' => [ + 'table' => 'structs.struct', + 'columns' => 'id, index, type, creator, owner, location_type, location_id, + operating_ambit, slot, is_destroyed, destroyed_block, created_at, updated_at', + ], + 'allocation' => [ + 'table' => 'structs.allocation', + 'columns' => 'id, allocation_type, source_id, index, destination_id, controller, + creator, created_at, updated_at', + ], + 'fleet' => [ + 'table' => 'structs.fleet', + 'columns' => 'id, owner, map, space_slots, air_slots, land_slots, water_slots, + location_type, location_id, status, location_list_forward, + location_list_backward, command_struct, created_at, updated_at', + ], + 'provider' => [ + 'table' => 'structs.provider', + 'columns' => 'id, index, substation_id, rate_amount, rate_denom, access_policy, + capacity_minimum, capacity_maximum, duration_minimum, duration_maximum, + provider_cancellation_penalty, consumer_cancellation_penalty, + creator, owner, created_at, updated_at', + ], + 'agreement' => [ + 'table' => 'structs.agreement', + 'columns' => 'id, provider_id, allocation_id, capacity, start_block, end_block, + creator, owner, created_at, updated_at', + ], ]; public EntityManagerInterface $entityManager; @@ -285,13 +340,12 @@ public function getObjects(?string $ids): Response */ private function fetchTypedObject(string $type, string $id): ?array { - // 'address' keys (8-N) have no backing table, so they resolve to null here. - $table = self::TYPE_TABLES[$type] ?? null; - if ($table === null) { + $resolvable = self::TYPE_TABLES[$type] ?? null; + if ($resolvable === null) { return null; } $row = $this->entityManager->getConnection()->fetchAssociative( - "SELECT * FROM {$table} WHERE id = :id LIMIT 1", + "SELECT {$resolvable['columns']} FROM {$resolvable['table']} WHERE id = :id LIMIT 1", ['id' => $id] );