From 39f26e616d2936ad567a11aca1e08a86a0b48253 Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Sun, 1 Feb 2026 10:57:26 -0800 Subject: [PATCH] feat: add codemod for upcoming v0.55.0-v0.56.0 --- .../v0.55.0-v0.56.0/test1.input.ts | 37 ++++ .../v0.55.0-v0.56.0/test1.output.ts | 37 ++++ .../v0.55.0-v0.56.0/test2.input.ts | 33 ++++ .../v0.55.0-v0.56.0/test2.output.ts | 33 ++++ .../__tests__/v0.55.0-v0.56.0-test.ts | 17 ++ .../src/transforms/v0.55.0-v0.56.0.ts | 169 ++++++++++++++++++ 6 files changed, 326 insertions(+) create mode 100644 packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test1.input.ts create mode 100644 packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test1.output.ts create mode 100644 packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test2.input.ts create mode 100644 packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test2.output.ts create mode 100644 packages/entity-codemod/src/transforms/__tests__/v0.55.0-v0.56.0-test.ts create mode 100644 packages/entity-codemod/src/transforms/v0.55.0-v0.56.0.ts diff --git a/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test1.input.ts b/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test1.input.ts new file mode 100644 index 000000000..04dc844cd --- /dev/null +++ b/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test1.input.ts @@ -0,0 +1,37 @@ +import { ViewerContext } from '@expo/entity'; +import { UserEntity } from './entities/UserEntity'; +import { PostEntity } from './entities/PostEntity'; + +async function loadUser(viewerContext: ViewerContext) { + // Basic loader calls - only transformed when using knex-specific methods + const userLoader = UserEntity.loader(viewerContext); + const postLoader = PostEntity.loader(viewerContext); + + // These use knex-specific methods, so they should be transformed + const posts = await postLoader.loadManyByFieldEqualityConjunctionAsync([ + { fieldName: 'status', fieldValue: 'published' } + ]); + const firstPost = await postLoader.loadFirstByFieldEqualityConjunctionAsync([ + { fieldName: 'id', fieldValue: '123' } + ]); + + // Loader with authorization results - only transformed when using knex methods + const userLoaderWithAuth = UserEntity.loaderWithAuthorizationResults(viewerContext); + const rawResults = await userLoaderWithAuth.loadManyByRawWhereClauseAsync('age > ?', [18]); + + // Loader that doesn't use knex methods - should NOT be transformed + const standardLoader = PostEntity.loader(viewerContext); + const post = await standardLoader.loadByIDAsync('456'); + + // Should not transform instance methods or other properties + const user = await userLoader.loadByIDAsync('123'); + const userLoadMethod = user.loader; // This should not be transformed + + // Should not transform lowercase object methods + const customLoader = { + loader: (ctx: any) => ctx, + }; + customLoader.loader(viewerContext); + + return user; +} \ No newline at end of file diff --git a/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test1.output.ts b/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test1.output.ts new file mode 100644 index 000000000..c16f3d612 --- /dev/null +++ b/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test1.output.ts @@ -0,0 +1,37 @@ +import { ViewerContext } from '@expo/entity'; +import { UserEntity } from './entities/UserEntity'; +import { PostEntity } from './entities/PostEntity'; + +async function loadUser(viewerContext: ViewerContext) { + // Basic loader calls - only transformed when using knex-specific methods + const userLoader = UserEntity.loader(viewerContext); + const postLoader = PostEntity.knexLoader(viewerContext); + + // These use knex-specific methods, so they should be transformed + const posts = await postLoader.loadManyByFieldEqualityConjunctionAsync([ + { fieldName: 'status', fieldValue: 'published' } + ]); + const firstPost = await postLoader.loadFirstByFieldEqualityConjunctionAsync([ + { fieldName: 'id', fieldValue: '123' } + ]); + + // Loader with authorization results - only transformed when using knex methods + const userLoaderWithAuth = UserEntity.knexLoaderWithAuthorizationResults(viewerContext); + const rawResults = await userLoaderWithAuth.loadManyByRawWhereClauseAsync('age > ?', [18]); + + // Loader that doesn't use knex methods - should NOT be transformed + const standardLoader = PostEntity.loader(viewerContext); + const post = await standardLoader.loadByIDAsync('456'); + + // Should not transform instance methods or other properties + const user = await userLoader.loadByIDAsync('123'); + const userLoadMethod = user.loader; // This should not be transformed + + // Should not transform lowercase object methods + const customLoader = { + loader: (ctx: any) => ctx, + }; + customLoader.loader(viewerContext); + + return user; +} \ No newline at end of file diff --git a/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test2.input.ts b/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test2.input.ts new file mode 100644 index 000000000..d1a1e9b43 --- /dev/null +++ b/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test2.input.ts @@ -0,0 +1,33 @@ +import { ViewerContext } from '@expo/entity'; +import { CommentEntity } from './entities/CommentEntity'; + +// Chained calls +const loadComments = async (viewerContext: ViewerContext) => { + // Direct chaining with knex-specific method + const comments = await CommentEntity.loader(viewerContext) + .loadManyByFieldEqualityConjunctionAsync([ + { fieldName: 'postId', fieldValue: '123' } + ]); + + // Direct chaining with regular method - should NOT be transformed + const singleComment = await CommentEntity + .loader(viewerContext) + .loadByIDAsync('456'); + + // With authorization results and knex method + const commentsWithAuth = await CommentEntity + .loaderWithAuthorizationResults(viewerContext) + .loadManyByRawWhereClauseAsync('postId = ?', ['456']); + + // Edge cases - these should NOT be transformed + const anotherEntity = { + loader: (ctx: any) => ctx, // This is not an entity class + }; + anotherEntity.loader(viewerContext); // Should NOT be transformed (lowercase object) + + // Complex chaining with regular method - should NOT be transformed + return CommentEntity + .loader(viewerContext) + .withAuthenticationResults() + .loadByIDAsync('789'); +}; \ No newline at end of file diff --git a/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test2.output.ts b/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test2.output.ts new file mode 100644 index 000000000..4c61115d1 --- /dev/null +++ b/packages/entity-codemod/src/transforms/__testfixtures__/v0.55.0-v0.56.0/test2.output.ts @@ -0,0 +1,33 @@ +import { ViewerContext } from '@expo/entity'; +import { CommentEntity } from './entities/CommentEntity'; + +// Chained calls +const loadComments = async (viewerContext: ViewerContext) => { + // Direct chaining with knex-specific method + const comments = await CommentEntity.knexLoader(viewerContext) + .loadManyByFieldEqualityConjunctionAsync([ + { fieldName: 'postId', fieldValue: '123' } + ]); + + // Direct chaining with regular method - should NOT be transformed + const singleComment = await CommentEntity + .loader(viewerContext) + .loadByIDAsync('456'); + + // With authorization results and knex method + const commentsWithAuth = await CommentEntity + .knexLoaderWithAuthorizationResults(viewerContext) + .loadManyByRawWhereClauseAsync('postId = ?', ['456']); + + // Edge cases - these should NOT be transformed + const anotherEntity = { + loader: (ctx: any) => ctx, // This is not an entity class + }; + anotherEntity.loader(viewerContext); // Should NOT be transformed (lowercase object) + + // Complex chaining with regular method - should NOT be transformed + return CommentEntity + .loader(viewerContext) + .withAuthenticationResults() + .loadByIDAsync('789'); +}; \ No newline at end of file diff --git a/packages/entity-codemod/src/transforms/__tests__/v0.55.0-v0.56.0-test.ts b/packages/entity-codemod/src/transforms/__tests__/v0.55.0-v0.56.0-test.ts new file mode 100644 index 000000000..5daa8071d --- /dev/null +++ b/packages/entity-codemod/src/transforms/__tests__/v0.55.0-v0.56.0-test.ts @@ -0,0 +1,17 @@ +import { jest } from '@jest/globals'; +import { readdirSync } from 'fs'; +import { join } from 'path'; + +jest.autoMockOff(); +const defineTest = require('jscodeshift/dist/testUtils').defineTest; + +const fixtureDir = 'v0.55.0-v0.56.0'; +const fixtureDirPath = join(__dirname, '..', '__testfixtures__', fixtureDir); +const fixtures = readdirSync(fixtureDirPath) + .filter((file) => file.endsWith('.input.ts')) + .map((file) => file.replace('.input.ts', '')); + +for (const fixture of fixtures) { + const prefix = `${fixtureDir}/${fixture}`; + defineTest(__dirname, 'v0.55.0-v0.56.0', null, prefix, { parser: 'ts' }); +} diff --git a/packages/entity-codemod/src/transforms/v0.55.0-v0.56.0.ts b/packages/entity-codemod/src/transforms/v0.55.0-v0.56.0.ts new file mode 100644 index 000000000..bece47d68 --- /dev/null +++ b/packages/entity-codemod/src/transforms/v0.55.0-v0.56.0.ts @@ -0,0 +1,169 @@ +import { API, Collection, FileInfo, Options } from 'jscodeshift'; + +const KNEX_SPECIFIC_METHODS = [ + 'loadFirstByFieldEqualityConjunctionAsync', + 'loadManyByFieldEqualityConjunctionAsync', + 'loadManyByRawWhereClauseAsync', +]; + +function isKnexSpecificMethodUsed(j: API['jscodeshift'], node: any): boolean { + // Check if this loader call is followed by a knex-specific method + // We need to traverse the AST to find usages of the loader result + const parent = node.parent; + + // Check if the loader call is directly chained with a knex method + if (parent && parent.value.type === 'MemberExpression' && parent.value.object === node.value) { + const grandParent = parent.parent; + if ( + grandParent && + grandParent.value.type === 'CallExpression' && + grandParent.value.callee === parent.value + ) { + if ( + parent.value.property.type === 'Identifier' && + KNEX_SPECIFIC_METHODS.includes(parent.value.property.name) + ) { + return true; + } + } + } + + // Check if the loader is assigned to a variable and then used with knex methods + if (parent && parent.value.type === 'VariableDeclarator' && parent.value.init === node.value) { + const variableName = parent.value.id.name; + const scope = parent.scope; + + // Find all references to this variable in the same scope + const references = j(scope.path) + .find(j.Identifier, { name: variableName }) + .filter((path) => { + // Check if this identifier is used as object in member expression + const parentNode = path.parent.value; + if (parentNode.type === 'MemberExpression' && parentNode.object === path.value) { + const prop = parentNode.property; + if (prop.type === 'Identifier' && KNEX_SPECIFIC_METHODS.includes(prop.name)) { + return true; + } + } + return false; + }); + + if (references.size() > 0) { + return true; + } + } + + // Check await expressions + if (parent && parent.value.type === 'AwaitExpression' && parent.value.argument === node.value) { + const awaitParent = parent.parent; + if (awaitParent && awaitParent.value.type === 'VariableDeclarator') { + const variableName = awaitParent.value.id.name; + const scope = awaitParent.scope; + + // Find all references to this variable in the same scope + const references = j(scope.path) + .find(j.Identifier, { name: variableName }) + .filter((path) => { + const parentNode = path.parent.value; + if (parentNode.type === 'MemberExpression' && parentNode.object === path.value) { + const prop = parentNode.property; + if (prop.type === 'Identifier' && KNEX_SPECIFIC_METHODS.includes(prop.name)) { + return true; + } + } + return false; + }); + + if (references.size() > 0) { + return true; + } + } + } + + return false; +} + +function transformLoaderToKnexLoader(j: API['jscodeshift'], root: Collection): void { + // Find all entity expressions of the form `Entity.loader(viewerContext)` + root + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + property: { + name: 'loader', + }, + }, + }) + .forEach((path) => { + const loaderCallExpression = path.node; // Entity.loader(viewerContext) + const loaderCallee = loaderCallExpression.callee; // Entity.loader + + if (loaderCallee.type !== 'MemberExpression') { + return; + } + + // Make sure this is a static method call on an entity (not on an instance) + // Typically entity names start with uppercase letter + if (loaderCallee.object.type === 'Identifier') { + const firstChar = loaderCallee.object.name[0]; + if (firstChar && firstChar === firstChar.toUpperCase()) { + // Check if this loader uses knex-specific methods + if (isKnexSpecificMethodUsed(j, path)) { + // Rename loader to knexLoader + if (loaderCallee.property.type === 'Identifier') { + loaderCallee.property.name = 'knexLoader'; + } + } + } + } + }); +} + +function transformLoaderWithAuthorizationResultsToKnexLoaderWithAuthorizationResults( + j: API['jscodeshift'], + root: Collection, +): void { + // Find all entity expressions of the form `Entity.loaderWithAuthorizationResults(viewerContext)` + root + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + property: { + name: 'loaderWithAuthorizationResults', + }, + }, + }) + .forEach((path) => { + const loaderCallExpression = path.node; // Entity.loaderWithAuthorizationResults(viewerContext) + const loaderCallee = loaderCallExpression.callee; // Entity.loaderWithAuthorizationResults + + if (loaderCallee.type !== 'MemberExpression') { + return; + } + + // Make sure this is a static method call on an entity (not on an instance) + // Typically entity names start with uppercase letter + if (loaderCallee.object.type === 'Identifier') { + const firstChar = loaderCallee.object.name[0]; + if (firstChar && firstChar === firstChar.toUpperCase()) { + // Check if this loader uses knex-specific methods + if (isKnexSpecificMethodUsed(j, path)) { + // Rename loaderWithAuthorizationResults to knexLoaderWithAuthorizationResults + if (loaderCallee.property.type === 'Identifier') { + loaderCallee.property.name = 'knexLoaderWithAuthorizationResults'; + } + } + } + } + }); +} + +export default function transformer(file: FileInfo, api: API, _options: Options): string { + const j = api.jscodeshift; + const root = j.withParser('ts')(file.source); + + transformLoaderToKnexLoader(j, root); + transformLoaderWithAuthorizationResultsToKnexLoaderWithAuthorizationResults(j, root); + + return root.toSource(); +}