1: <?php declare(strict_types=1);
2:
3: namespace Salient\Sync\Command;
4:
5: use Salient\Cli\Exception\CliInvalidArgumentsException;
6: use Salient\Cli\CliOption;
7: use Salient\Contract\Cli\CliOptionType;
8: use Salient\Contract\Sync\Exception\SyncEntityNotFoundExceptionInterface;
9: use Salient\Contract\Sync\DeferralPolicy;
10: use Salient\Contract\Sync\HydrationPolicy;
11: use Salient\Contract\Sync\SyncEntityInterface;
12: use Salient\Contract\Sync\SyncProviderInterface;
13: use Salient\Core\Facade\Console;
14: use Salient\Sync\Support\SyncIntrospector;
15: use Salient\Sync\SyncSerializeRules;
16: use Salient\Utility\Arr;
17: use Salient\Utility\File;
18: use Salient\Utility\Get;
19: use Salient\Utility\Inflect;
20: use Salient\Utility\Json;
21: use InvalidArgumentException;
22:
23: /**
24: * A generic sync entity retrieval command
25: *
26: * @api
27: *
28: * @template T of SyncEntityInterface
29: */
30: final class GetSyncEntity extends AbstractSyncCommand
31: {
32: private string $EntityBasename = '';
33: /** @var class-string<SyncEntityInterface> */
34: private string $Entity = SyncEntityInterface::class;
35: private ?string $EntityId = null;
36: private ?string $ProviderBasename = null;
37: /** @var class-string<SyncProviderInterface> */
38: private string $Provider = SyncProviderInterface::class;
39: /** @var string[] */
40: private array $Filter = [];
41: private bool $Shallow = false;
42: private bool $IncludeCanonicalId = false;
43: private bool $IncludeMeta = false;
44: private bool $Stream = false;
45: /** @var string[] */
46: private array $Field = [];
47: private bool $Csv = false;
48:
49: // --
50:
51: /** @var array<string,string> */
52: private array $Fields;
53:
54: public function getDescription(): string
55: {
56: return 'Get entities from ' . (
57: $this->Entities
58: ? 'a registered provider'
59: : 'a provider'
60: );
61: }
62:
63: protected function getOptionList(): iterable
64: {
65: $entityBuilder = CliOption::build()
66: ->name('entity')
67: ->required();
68:
69: $entityIdBuilder = CliOption::build()
70: ->name('entity_id')
71: ->description(<<<EOF
72: The unique identifier of an entity to request
73:
74: If not given, a list of entities is requested.
75: EOF)
76: ->optionType(CliOptionType::VALUE_POSITIONAL)
77: ->bindTo($this->EntityId);
78:
79: $providerBuilder = CliOption::build()
80: ->long('provider')
81: ->short('p')
82: ->valueName('provider');
83:
84: if ($this->Entities) {
85: yield from [
86: $entityBuilder
87: ->optionType(CliOptionType::ONE_OF_POSITIONAL)
88: ->allowedValues(array_keys($this->Entities))
89: ->bindTo($this->EntityBasename),
90: $entityIdBuilder,
91: $providerBuilder
92: ->description(<<<EOF
93: The provider to request entities from
94:
95: If not given, the entity's default provider is used.
96: EOF)
97: ->optionType(CliOptionType::ONE_OF)
98: ->allowedValues(array_keys($this->EntityProviders))
99: ->bindTo($this->ProviderBasename),
100: ];
101: } else {
102: yield from [
103: $entityBuilder
104: ->description('The fully-qualified name of the entity to request')
105: ->optionType(CliOptionType::VALUE_POSITIONAL)
106: ->bindTo($this->Entity),
107: $entityIdBuilder,
108: $providerBuilder
109: ->description('The fully-qualified name of the provider to request entities from')
110: ->optionType(CliOptionType::VALUE)
111: ->required()
112: ->bindTo($this->Provider),
113: ];
114: }
115:
116: yield from [
117: CliOption::build()
118: ->long('filter')
119: ->short('f')
120: ->valueName('term=value')
121: ->description('Apply a filter to the request')
122: ->optionType(CliOptionType::VALUE)
123: ->multipleAllowed()
124: ->bindTo($this->Filter),
125: CliOption::build()
126: ->long('shallow')
127: ->description('Do not resolve entity relationships')
128: ->bindTo($this->Shallow),
129: CliOption::build()
130: ->long('include-canonical-id')
131: ->short('I')
132: ->description('Include canonical_id in the output')
133: ->bindTo($this->IncludeCanonicalId),
134: CliOption::build()
135: ->long('include-meta')
136: ->short('M')
137: ->description('Include meta values in the output')
138: ->bindTo($this->IncludeMeta),
139: CliOption::build()
140: ->long('stream')
141: ->short('s')
142: ->description('Output a stream of entities')
143: ->bindTo($this->Stream),
144: CliOption::build()
145: ->long('field')
146: ->short('F')
147: ->valueName('(<field>|<field>=<title>)')
148: ->description('Limit output to the given fields, e.g. "id,user.id=user id,title"')
149: ->optionType(CliOptionType::VALUE)
150: ->multipleAllowed()
151: ->bindTo($this->Field),
152: CliOption::build()
153: ->long('csv')
154: ->short('c')
155: ->description('Generate CSV output (implies `--shallow` if `--field` is not given)')
156: ->bindTo($this->Csv),
157: ];
158:
159: yield from $this->getGlobalOptionList();
160: }
161:
162: protected function run(string ...$args)
163: {
164: $this->startRun();
165:
166: $this->Fields = [];
167:
168: if ($this->Entities) {
169: /** @var class-string<T> */
170: $entity = $this->Entities[$this->EntityBasename];
171:
172: $provider = $this->ProviderBasename ?? array_search(
173: $this->App->getName(SyncIntrospector::entityToProvider($entity)),
174: $this->Providers,
175: );
176:
177: if ($provider === false) {
178: throw new CliInvalidArgumentsException(
179: sprintf('no default provider: %s', $entity)
180: );
181: }
182:
183: $provider = $this->Providers[$provider];
184: } else {
185: /** @var class-string<T> */
186: $entity = $this->Entity;
187: $provider = $this->Provider;
188:
189: if (!is_a(
190: $this->App->getName($entity),
191: SyncEntityInterface::class,
192: true
193: )) {
194: throw new CliInvalidArgumentsException(sprintf(
195: '%s does not implement %s',
196: $entity,
197: SyncEntityInterface::class,
198: ));
199: }
200:
201: if (!is_a(
202: $this->App->getName($provider),
203: SyncProviderInterface::class,
204: true
205: )) {
206: throw new CliInvalidArgumentsException(sprintf(
207: '%s does not implement %s',
208: $provider,
209: SyncProviderInterface::class,
210: ));
211: }
212:
213: if (!$this->App->has($provider)) {
214: $this->App->singleton($provider);
215: }
216: }
217:
218: $provider = $this->App->get($provider);
219:
220: $entityProvider = SyncIntrospector::entityToProvider($entity);
221: if (!$provider instanceof $entityProvider) {
222: throw new CliInvalidArgumentsException(sprintf(
223: '%s does not service %s',
224: get_class($provider),
225: $entity,
226: ));
227: }
228:
229: try {
230: $filter = Get::filter($this->Filter);
231: } catch (InvalidArgumentException $ex) {
232: throw new CliInvalidArgumentsException(sprintf(
233: 'invalid filter (%s)',
234: $ex->getMessage(),
235: ));
236: }
237:
238: $context = $provider->getContext();
239: if ($this->Shallow || ($this->Csv && !$this->Field)) {
240: $context = $context
241: ->withDeferralPolicy(DeferralPolicy::DO_NOT_RESOLVE)
242: ->withHydrationPolicy(HydrationPolicy::SUPPRESS);
243: }
244:
245: /** @var SyncSerializeRules<T> */
246: $rules = $entity::getSerializeRules()
247: ->withIncludeCanonicalId($this->IncludeCanonicalId)
248: ->withIncludeMeta($this->IncludeMeta);
249:
250: if ($this->Field) {
251: foreach ($this->Field as $field) {
252: $field = explode('=', $field, 2);
253: $this->Fields[$field[0]] = $field[1] ?? $field[0];
254: }
255: }
256:
257: Console::info(
258: sprintf('Retrieving from %s:', $provider->getName()),
259: ($this->Store->getEntityUri($entity)
260: ?? '/' . str_replace('\\', '/', $entity))
261: . ($this->EntityId === null ? '' : '/' . $this->EntityId)
262: );
263:
264: if ($this->EntityId === null) {
265: $result = $this->Stream
266: ? $provider->with($entity, $context)->getList($filter)
267: : $provider->with($entity, $context)->getListA($filter);
268: } else {
269: try {
270: $result = $provider->with($entity, $context)->get($this->EntityId, $filter);
271: } catch (SyncEntityNotFoundExceptionInterface $ex) {
272: throw new CliInvalidArgumentsException(
273: sprintf(
274: 'entity not found: %s (%s)',
275: $this->EntityId,
276: $ex->getMessage(),
277: )
278: );
279: }
280: }
281:
282: $stdout = Console::getStdoutTarget();
283: $tty = $stdout->isTty();
284:
285: if ($this->Csv) {
286: if ($this->EntityId !== null) {
287: $result = [$result];
288: }
289:
290: File::writeCsv(
291: 'php://output',
292: $result,
293: true,
294: null,
295: fn(SyncEntityInterface $entity, int $index) =>
296: $this->serialize($entity, $rules, !$index),
297: $count,
298: $tty ? $stdout->getEol() : "\r\n",
299: !$tty,
300: !$tty,
301: );
302: } elseif ($this->Stream && $this->EntityId === null) {
303: $eol = $tty ? $stdout->getEol() : \PHP_EOL;
304: $count = 0;
305: foreach ($result as $entity) {
306: echo Json::prettyPrint($this->serialize($entity, $rules, !$count), 0, $eol) . $eol;
307: $count++;
308: }
309: } else {
310: if ($this->EntityId !== null) {
311: $result = [$result];
312: }
313:
314: $output = [];
315: $count = 0;
316: foreach ($result as $entity) {
317: $output[] = $this->serialize($entity, $rules, !$count);
318: $count++;
319: }
320:
321: if ($this->EntityId !== null) {
322: $output = array_shift($output);
323: }
324:
325: $eol = $tty ? $stdout->getEol() : \PHP_EOL;
326: echo Json::prettyPrint($output, 0, $eol) . $eol;
327: }
328:
329: Console::summary(Inflect::format(
330: $count,
331: '{{#}} {{#:entity}} retrieved',
332: ));
333: }
334:
335: /**
336: * @param T $entity
337: * @param SyncSerializeRules<T> $rules
338: * @return mixed[]
339: */
340: private function serialize(
341: SyncEntityInterface $entity,
342: SyncSerializeRules $rules,
343: bool $check = false
344: ): array {
345: $entity = $entity->toArrayWith($rules);
346: if (!$this->Fields) {
347: return $entity;
348: }
349: foreach ($this->Fields as $field => $name) {
350: if ($check && !Arr::has($entity, $field)) {
351: throw new CliInvalidArgumentsException(sprintf(
352: 'Invalid field: %s',
353: $field,
354: ));
355: }
356: $result[$name] = Arr::get($entity, $field, null);
357: }
358: return $result;
359: }
360: }
361: